From 49ddeea4349cfa9bbe11ce2bfe437bb0036fb92c Mon Sep 17 00:00:00 2001 From: Kamil Jarosz Date: Mon, 7 Jul 2025 19:14:09 +0200 Subject: [PATCH 1/2] font: Refactor glyph loading into load_glyph --- core/src/font.rs | 68 +++++++++++++++++++++++++----------------------- 1 file changed, 35 insertions(+), 33 deletions(-) diff --git a/core/src/font.rs b/core/src/font.rs index 8c56e92401ac..bf7eeb2f94ae 100644 --- a/core/src/font.rs +++ b/core/src/font.rs @@ -11,6 +11,7 @@ use std::borrow::Cow; use std::cell::{OnceCell, RefCell}; use std::hash::{Hash, Hasher}; use swf::FillStyle; +use ttf_parser::{Face, GlyphId}; pub use swf::TextGridFit; @@ -207,7 +208,7 @@ impl FontFace { // TODO: Support font collections // We validate that the font is good here, so we can just `.expect()` it later - let face = ttf_parser::Face::parse(&bytes, font_index)?; + let face = Face::parse(&bytes, font_index)?; let ascender = face.ascender() as i32; let descender = -face.descender() as i32; @@ -240,51 +241,52 @@ impl FontFace { } pub fn get_glyph(&self, character: char) -> Option<&Glyph> { - let face = ttf_parser::Face::parse(&self.bytes, self.font_index) + let face = Face::parse(&self.bytes, self.font_index) .expect("Font was already checked to be valid"); if let Some(glyph_id) = face.glyph_index(character) { return self.glyphs[glyph_id.0 as usize] - .get_or_init(|| { - let mut drawing = Drawing::new(); - // TTF uses NonZero - drawing.new_fill( - Some(FillStyle::Color(Color::WHITE)), - Some(FillRule::NonZero), - ); - if face - .outline_glyph(glyph_id, &mut GlyphToDrawing(&mut drawing)) - .is_some() - { - let advance = face.glyph_hor_advance(glyph_id).map_or_else( - || drawing.self_bounds().width(), - |a| Twips::new(a as i32), - ); - Some(Glyph { - shape: GlyphShape::Drawing(Box::new(drawing)), - advance, - character, - }) - } else { - let advance = Twips::new(face.glyph_hor_advance(glyph_id)? as i32); - // If we have advance, then this is either an image, SVG or simply missing (ie whitespace) - Some(Glyph { - shape: GlyphShape::None, - advance, - character, - }) - } - }) + .get_or_init(|| self.load_glyph(&face, character, glyph_id)) .as_ref(); } None } + fn load_glyph<'a>(&self, face: &Face<'a>, character: char, glyph_id: GlyphId) -> Option { + let mut drawing = Drawing::new(); + // TTF uses NonZero + drawing.new_fill( + Some(FillStyle::Color(Color::WHITE)), + Some(FillRule::NonZero), + ); + if face + .outline_glyph(glyph_id, &mut GlyphToDrawing(&mut drawing)) + .is_some() + { + let advance = face + .glyph_hor_advance(glyph_id) + .map_or_else(|| drawing.self_bounds().width(), |a| Twips::new(a as i32)); + Some(Glyph { + shape: GlyphShape::Drawing(Box::new(drawing)), + advance, + character, + }) + } else { + let advance = Twips::new(face.glyph_hor_advance(glyph_id)? as i32); + // If we have advance, then this is either an image, SVG or simply missing (ie whitespace) + Some(Glyph { + shape: GlyphShape::None, + advance, + character, + }) + } + } + pub fn has_kerning_info(&self) -> bool { self.might_have_kerning } pub fn get_kerning_offset(&self, left: char, right: char) -> Twips { - let face = ttf_parser::Face::parse(&self.bytes, self.font_index) + let face = Face::parse(&self.bytes, self.font_index) .expect("Font was already checked to be valid"); if let (Some(left_glyph), Some(right_glyph)) = From eb616fa250eeb1543db1e290e3d246be033ed47a Mon Sep 17 00:00:00 2001 From: Kamil Jarosz Date: Mon, 7 Jul 2025 19:31:50 +0200 Subject: [PATCH 2/2] core: Allow loading fonts into memory lazily This patch improves memory footprint by preventing fonts from being loaded eagerly into memory. Instead, it's possible to pass a file reference to a font, which will be used to load data from the font file when needed. This mechanism is used on desktop. This patch also introduces a preloading mechanism, that makes sure we read from the font file once per a text fragment, and not for each glyph. --- core/src/backend/ui.rs | 7 +- core/src/font.rs | 191 +++++++++++++++++++++++++---- core/src/lib.rs | 2 +- core/src/library.rs | 13 +- core/src/player.rs | 3 +- core/src/tag_utils.rs | 3 +- desktop/src/backends/ui.rs | 10 +- tests/framework/src/backends/ui.rs | 4 +- web/src/builder.rs | 6 +- 9 files changed, 192 insertions(+), 47 deletions(-) diff --git a/core/src/backend/ui.rs b/core/src/backend/ui.rs index 5e59f939d414..3fb3f6b126db 100644 --- a/core/src/backend/ui.rs +++ b/core/src/backend/ui.rs @@ -1,5 +1,8 @@ pub use crate::loader::Error as DialogLoaderError; -use crate::{backend::navigator::OwnedFuture, font::FontQuery}; +use crate::{ + backend::navigator::OwnedFuture, + font::{FontQuery, FontSource}, +}; use chrono::{DateTime, Utc}; use fluent_templates::loader::langid; pub use fluent_templates::LanguageIdentifier; @@ -18,7 +21,7 @@ pub enum FontDefinition<'a> { name: String, is_bold: bool, is_italic: bool, - data: Vec, + source: FontSource, index: u32, }, } diff --git a/core/src/font.rs b/core/src/font.rs index bf7eeb2f94ae..6caa7b7877a8 100644 --- a/core/src/font.rs +++ b/core/src/font.rs @@ -9,8 +9,12 @@ use ruffle_render::shape_utils::{DrawCommand, FillRule}; use ruffle_render::transform::Transform; use std::borrow::Cow; use std::cell::{OnceCell, RefCell}; +use std::collections::HashMap; +use std::fs::File; use std::hash::{Hash, Hasher}; +use std::io::{Read, Seek}; use swf::FillStyle; +use thiserror::Error; use ttf_parser::{Face, GlyphId}; pub use swf::TextGridFit; @@ -105,6 +109,84 @@ fn round_to_pixel(t: Twips) -> Twips { Twips::from_pixels(t.to_pixels().round()) } +#[derive(Debug, Error)] +pub enum FontError { + #[error("IO error: {0}")] + IoError(#[from] std::io::Error), + + #[error("TTF parsing error: {0}")] + TtfError(#[from] ttf_parser::FaceParsingError), +} + +#[derive(Debug)] +pub enum FontSource { + Bytes(Cow<'static, [u8]>), + File(RefCell), +} + +impl FontSource { + pub fn from_bytes(bytes: Vec) -> Self { + Self::Bytes(Cow::Owned(bytes)) + } + + pub fn from_file(file: File) -> Self { + Self::File(RefCell::new(file)) + } + + fn read_bytes(&self) -> Result, FontError> { + match self { + FontSource::Bytes(bytes) => Ok(Cow::Borrowed(bytes.as_ref())), + FontSource::File(file) => { + let mut file = file.borrow_mut(); + let mut data = Vec::new(); + file.seek(std::io::SeekFrom::Start(0)) + .and_then(|_| file.read_to_end(&mut data)) + .map(|_| Cow::Owned(data)) + .map_err(FontError::IoError) + } + } + } + + fn try_read_bytes(&self) -> Option> { + self.read_bytes() + .inspect_err(|err| tracing::error!("Failed reading font file: {err}")) + .ok() + } +} + +struct LazyFace<'a> { + preload: bool, + source: &'a FontSource, + font_index: u32, + bytes: OnceCell>>, +} + +impl<'a> LazyFace<'a> { + fn new(source: &'a FontSource, font_index: u32, preload: bool) -> Self { + Self { + preload, + source, + font_index, + bytes: OnceCell::new(), + } + } + + fn face(&self) -> Option { + if !self.preload && cfg!(debug_assertions) { + panic!( + "Text should be preloaded! Make sure to preload text before \ + operating on its glyphs, it will speed up execution." + ); + } + + if let Some(ref bytes) = self.bytes.get_or_init(|| self.source.try_read_bytes()) { + Face::parse(bytes, self.font_index).ok() + } else { + None + } + } +} + /// Parameters necessary to evaluate a font. #[derive(Copy, Clone, Debug)] pub struct EvalParameters { @@ -189,7 +271,8 @@ impl ttf_parser::OutlineBuilder for GlyphToDrawing<'_> { /// Glyph from the same file. For this reason, glyphs are reused where possible. #[derive(Debug)] pub struct FontFace { - bytes: Cow<'static, [u8]>, + source: FontSource, + glyph_indices: RefCell>>, glyphs: Vec>>, font_index: u32, @@ -201,12 +284,11 @@ pub struct FontFace { } impl FontFace { - pub fn new( - bytes: Cow<'static, [u8]>, - font_index: u32, - ) -> Result { + pub fn new(source: FontSource, font_index: u32) -> Result { // TODO: Support font collections + let bytes = source.read_bytes()?; + // We validate that the font is good here, so we can just `.expect()` it later let face = Face::parse(&bytes, font_index)?; @@ -229,8 +311,9 @@ impl FontFace { .unwrap_or_default(); Ok(Self { - bytes, + source, font_index, + glyph_indices: RefCell::new(HashMap::new()), glyphs, ascender, descender, @@ -240,18 +323,54 @@ impl FontFace { }) } + fn lazy_face(&self, preload: bool) -> LazyFace { + LazyFace::new(&self.source, self.font_index, preload) + } + + fn glyph_index(&self, face: &LazyFace, character: char) -> Option { + *self + .glyph_indices + .borrow_mut() + .entry(character) + .or_insert_with(|| face.face()?.glyph_index(character)) + } + + pub fn preload(&self, string: &WStr) { + let face = self.lazy_face(true); + + let chars = || string.chars().flat_map(|ch| ch.ok()); + for character in chars() { + self.get_or_load_glyph(&face, character); + } + + if self.has_kerning_info() { + let mut last_char = None; + for next_char in chars() { + if let Some(last_char) = last_char { + self.get_or_load_kerning_offset(&face, last_char, next_char); + } + last_char = Some(next_char); + } + } + } + pub fn get_glyph(&self, character: char) -> Option<&Glyph> { - let face = Face::parse(&self.bytes, self.font_index) - .expect("Font was already checked to be valid"); - if let Some(glyph_id) = face.glyph_index(character) { + let face = self.lazy_face(false); + self.get_or_load_glyph(&face, character) + } + + fn get_or_load_glyph(&self, face: &LazyFace, character: char) -> Option<&Glyph> { + if let Some(glyph_id) = self.glyph_index(face, character) { return self.glyphs[glyph_id.0 as usize] - .get_or_init(|| self.load_glyph(&face, character, glyph_id)) + .get_or_init(|| self.load_glyph(face, character, glyph_id)) .as_ref(); } None } - fn load_glyph<'a>(&self, face: &Face<'a>, character: char, glyph_id: GlyphId) -> Option { + fn load_glyph(&self, face: &LazyFace, character: char, glyph_id: GlyphId) -> Option { + let face = face.face()?; + let mut drawing = Drawing::new(); // TTF uses NonZero drawing.new_fill( @@ -286,12 +405,18 @@ impl FontFace { } pub fn get_kerning_offset(&self, left: char, right: char) -> Twips { - let face = Face::parse(&self.bytes, self.font_index) - .expect("Font was already checked to be valid"); + let face = self.lazy_face(false); + self.get_or_load_kerning_offset(&face, left, right) + } + fn get_or_load_kerning_offset(&self, face: &LazyFace, left: char, right: char) -> Twips { if let (Some(left_glyph), Some(right_glyph)) = - (face.glyph_index(left), face.glyph_index(right)) + (self.glyph_index(face, left), self.glyph_index(face, right)) { + let Some(face) = face.face() else { + return Twips::ZERO; + }; + if let Some(kern) = face.tables().kern { for subtable in kern.subtables { if subtable.horizontal { @@ -378,6 +503,14 @@ impl GlyphSource { GlyphSource::Empty => Twips::ZERO, } } + + pub fn preload_for_string(&self, string: &WStr) { + match self { + GlyphSource::Memory { .. } => {} + GlyphSource::FontFace(face) => face.preload(string), + GlyphSource::Empty => {} + } + } } #[derive(Debug, Clone, Copy, Eq, PartialEq, Collect, Hash)] @@ -436,11 +569,11 @@ impl<'gc> Font<'gc> { pub fn from_font_file( gc_context: &Mutation<'gc>, descriptor: FontDescriptor, - bytes: Cow<'static, [u8]>, + source: FontSource, font_index: u32, font_type: FontType, - ) -> Result, ttf_parser::FaceParsingError> { - let face = FontFace::new(bytes, font_index)?; + ) -> Result, FontError> { + let face = FontFace::new(source, font_index)?; Ok(Font(Gc::new( gc_context, @@ -541,7 +674,7 @@ impl<'gc> Font<'gc> { gc_context: &Mutation<'gc>, tag: swf::Font4, encoding: &'static swf::Encoding, - ) -> Result, ttf_parser::FaceParsingError> { + ) -> Result, FontError> { let name = tag.name.to_str_lossy(encoding); let descriptor = FontDescriptor::from_parts(&name, tag.is_bold, tag.is_italic); @@ -549,7 +682,7 @@ impl<'gc> Font<'gc> { Font::from_font_file( gc_context, descriptor, - Cow::Owned(bytes.to_vec()), + FontSource::from_bytes(bytes.to_vec()), 0, FontType::EmbeddedCFF, ) @@ -606,8 +739,14 @@ impl<'gc> Font<'gc> { self.0.glyphs.get_by_code_point(c) } + /// Preload glyphs for the given string. + pub fn preload_glyphs_for_string(&self, string: &WStr) { + self.0.glyphs.preload_for_string(string) + } + /// Determine if this font contains all the glyphs within a given string. pub fn has_glyphs_for_str(&self, target_str: &WStr) -> bool { + self.preload_glyphs_for_string(target_str); for character in target_str.chars() { let c = character.unwrap_or(char::REPLACEMENT_CHARACTER); if self.get_glyph_for_char(c).is_none() { @@ -686,6 +825,7 @@ impl<'gc> Font<'gc> { let mut char_indices = text.char_indices().peekable(); let has_kerning_info = self.has_kerning_info(); let mut x = Twips::ZERO; + self.preload_glyphs_for_string(text); while let Some((pos, c)) = char_indices.next() { let c = c.unwrap_or(char::REPLACEMENT_CHARACTER); if let Some(glyph) = self.get_glyph_for_char(c) { @@ -1207,11 +1347,11 @@ impl Default for TextRenderSettings { #[cfg(test)] mod tests { + use super::FontSource; use crate::font::{EvalParameters, Font, FontDescriptor, FontType}; use crate::string::WStr; use flate2::read::DeflateDecoder; use gc_arena::{arena::rootless_mutate, Mutation}; - use std::borrow::Cow; use std::io::Read; use swf::Twips; @@ -1242,9 +1382,14 @@ mod tests { .expect("default font decompression must succeed"); let descriptor = FontDescriptor::from_parts("Noto Sans", false, false); - let device_font = - Font::from_font_file(mc, descriptor, Cow::Owned(data), 0, FontType::Device) - .unwrap(); + let device_font = Font::from_font_file( + mc, + descriptor, + FontSource::from_bytes(data), + 0, + FontType::Device, + ) + .unwrap(); callback(mc, device_font); }) } diff --git a/core/src/lib.rs b/core/src/lib.rs index ad1c790cdbcf..fb6be224dd10 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -61,7 +61,7 @@ pub mod stub; pub use context_menu::ContextMenuItem; pub use events::PlayerEvent; -pub use font::{DefaultFont, FontQuery}; +pub use font::{DefaultFont, FontQuery, FontSource}; pub use indexmap; pub use loader::LoadBehavior; pub use player::{Player, PlayerBuilder, PlayerMode, PlayerRuntime, StaticCallstack}; diff --git a/core/src/library.rs b/core/src/library.rs index caba0648a072..64ec777053c5 100644 --- a/core/src/library.rs +++ b/core/src/library.rs @@ -2,7 +2,6 @@ use crate::avm1::{PropertyMap as Avm1PropertyMap, PropertyMap}; use crate::avm2::{Class as Avm2Class, Domain as Avm2Domain}; use crate::backend::audio::SoundHandle; use crate::character::Character; -use std::borrow::Cow; use crate::display_object::{Bitmap, Graphic, MorphShape, Text}; use crate::font::{Font, FontDescriptor, FontQuery, FontType}; @@ -691,17 +690,13 @@ impl<'gc> Library<'gc> { name, is_bold, is_italic, - data, + source, index, } => { let descriptor = FontDescriptor::from_parts(&name, is_bold, is_italic); - if let Ok(font) = Font::from_font_file( - gc_context, - descriptor, - Cow::Owned(data), - index, - FontType::Device, - ) { + if let Ok(font) = + Font::from_font_file(gc_context, descriptor, source, index, FontType::Device) + { let name = font.descriptor().name().to_owned(); info!("Loaded new device font \"{name}\" (bold: {is_bold}, italic: {is_italic}) from file"); self.device_fonts.register(font); diff --git a/core/src/player.rs b/core/src/player.rs index aa4597e3aa85..8ba50fc152ff 100644 --- a/core/src/player.rs +++ b/core/src/player.rs @@ -2945,6 +2945,7 @@ impl PlayerBuilder { #[cfg(feature = "default_font")] { + use crate::font::FontSource; use flate2::read::DeflateDecoder; use std::io::Read; @@ -2958,7 +2959,7 @@ impl PlayerBuilder { name: "Noto Sans".into(), is_bold: false, is_italic: false, - data, + source: FontSource::from_bytes(data), index: 0, }); diff --git a/core/src/tag_utils.rs b/core/src/tag_utils.rs index 36a86542825d..5b8921253385 100644 --- a/core/src/tag_utils.rs +++ b/core/src/tag_utils.rs @@ -5,6 +5,7 @@ use swf::{CharacterId, Fixed8, HeaderExt, Rectangle, TagCode, Twips}; use thiserror::Error; use url::Url; +use crate::font::FontError; use crate::sandbox::SandboxType; #[derive(Error, Debug)] @@ -16,7 +17,7 @@ pub enum Error { InvalidBitmap(#[from] ruffle_render::error::Error), #[error("Couldn't register font: {0}")] - InvalidFont(#[from] ttf_parser::FaceParsingError), + InvalidFont(#[from] FontError), #[error("Attempted to set symbol classes on movie without any")] NoSymbolClasses, diff --git a/desktop/src/backends/ui.rs b/desktop/src/backends/ui.rs index 6eb58bebf619..bec8216a6841 100644 --- a/desktop/src/backends/ui.rs +++ b/desktop/src/backends/ui.rs @@ -14,7 +14,7 @@ use ruffle_core::backend::ui::{ DialogLoaderError, DialogResultFuture, FileDialogResult, FileFilter, FontDefinition, FullscreenError, LanguageIdentifier, MouseCursor, UiBackend, }; -use ruffle_core::FontQuery; +use ruffle_core::{FontQuery, FontSource}; use std::path::Path; use std::rc::Rc; use std::sync::Arc; @@ -403,12 +403,12 @@ fn load_font_from_file( is_bold: bool, is_italic: bool, ) -> Result> { - match std::fs::read(path) { - Ok(data) => Ok(FontDefinition::FontFile { + match std::fs::File::open(path) { + Ok(file) => Ok(FontDefinition::FontFile { name, is_bold, is_italic, - data, + source: FontSource::from_file(file), index, }), Err(e) => Err(anyhow!("Couldn't read font file at {path:?}: {e}")), @@ -429,7 +429,7 @@ fn load_fontdb_font(name: String, face: &FaceInfo) -> Result