1use crate::{
2 AssetSource, DevicePixels, IsZero, RenderImage, Result, SharedString, Size,
3 swap_rgba_pa_to_bgra,
4};
5use image::Frame;
6use resvg::tiny_skia::Pixmap;
7use smallvec::SmallVec;
8use std::{
9 hash::Hash,
10 sync::{Arc, LazyLock},
11};
12
13#[cfg(target_os = "macos")]
14const EMOJI_FONT_FAMILIES: &[&str] = &["Apple Color Emoji", ".AppleColorEmojiUI"];
15
16#[cfg(target_os = "windows")]
17const EMOJI_FONT_FAMILIES: &[&str] = &["Segoe UI Emoji", "Segoe UI Symbol"];
18
19#[cfg(any(target_os = "linux", target_os = "freebsd"))]
20const EMOJI_FONT_FAMILIES: &[&str] = &[
21 "Noto Color Emoji",
22 "Emoji One",
23 "Twitter Color Emoji",
24 "JoyPixels",
25];
26
27#[cfg(not(any(
28 target_os = "macos",
29 target_os = "windows",
30 target_os = "linux",
31 target_os = "freebsd",
32)))]
33const EMOJI_FONT_FAMILIES: &[&str] = &[];
34
35fn is_emoji_presentation(c: char) -> bool {
36 static EMOJI_PRESENTATION_REGEX: LazyLock<regex::Regex> =
37 LazyLock::new(|| regex::Regex::new("\\p{Emoji_Presentation}").unwrap());
38 let mut buf = [0u8; 4];
39 EMOJI_PRESENTATION_REGEX.is_match(c.encode_utf8(&mut buf))
40}
41
42fn font_has_char(db: &usvg::fontdb::Database, id: usvg::fontdb::ID, ch: char) -> bool {
43 db.with_face_data(id, |font_data, face_index| {
44 ttf_parser::Face::parse(font_data, face_index)
45 .ok()
46 .and_then(|face| face.glyph_index(ch))
47 .is_some()
48 })
49 .unwrap_or(false)
50}
51
52fn select_emoji_font(
53 ch: char,
54 fonts: &[usvg::fontdb::ID],
55 db: &usvg::fontdb::Database,
56 families: &[&str],
57) -> Option<usvg::fontdb::ID> {
58 for family_name in families {
59 let query = usvg::fontdb::Query {
60 families: &[usvg::fontdb::Family::Name(family_name)],
61 weight: usvg::fontdb::Weight(400),
62 stretch: usvg::fontdb::Stretch::Normal,
63 style: usvg::fontdb::Style::Normal,
64 };
65
66 let Some(id) = db.query(&query) else {
67 continue;
68 };
69
70 if fonts.contains(&id) || !font_has_char(db, id, ch) {
71 continue;
72 }
73
74 return Some(id);
75 }
76
77 None
78}
79
80/// When rendering SVGs, we render them at twice the size to get a higher-quality result.
81pub const SMOOTH_SVG_SCALE_FACTOR: f32 = 2.;
82
83#[derive(Clone, PartialEq, Hash, Eq)]
84#[expect(missing_docs)]
85pub struct RenderSvgParams {
86 pub path: SharedString,
87 pub size: Size<DevicePixels>,
88}
89
90#[derive(Clone)]
91/// A struct holding everything necessary to render SVGs.
92pub struct SvgRenderer {
93 asset_source: Arc<dyn AssetSource>,
94 usvg_options: Arc<usvg::Options<'static>>,
95}
96
97/// The size in which to render the SVG.
98pub enum SvgSize {
99 /// An absolute size in device pixels.
100 Size(Size<DevicePixels>),
101 /// A scaling factor to apply to the size provided by the SVG.
102 ScaleFactor(f32),
103}
104
105impl SvgRenderer {
106 /// Creates a new SVG renderer with the provided asset source.
107 pub fn new(asset_source: Arc<dyn AssetSource>) -> Self {
108 static FONT_DB: LazyLock<Arc<usvg::fontdb::Database>> = LazyLock::new(|| {
109 let mut db = usvg::fontdb::Database::new();
110 db.load_system_fonts();
111 Arc::new(db)
112 });
113 let default_font_resolver = usvg::FontResolver::default_font_selector();
114 let font_resolver = Box::new(
115 move |font: &usvg::Font, db: &mut Arc<usvg::fontdb::Database>| {
116 if db.is_empty() {
117 *db = FONT_DB.clone();
118 }
119 default_font_resolver(font, db)
120 },
121 );
122 let default_fallback_selection = usvg::FontResolver::default_fallback_selector();
123 let fallback_selection = Box::new(
124 move |ch: char, fonts: &[usvg::fontdb::ID], db: &mut Arc<usvg::fontdb::Database>| {
125 if is_emoji_presentation(ch) {
126 if let Some(id) = select_emoji_font(ch, fonts, db.as_ref(), EMOJI_FONT_FAMILIES)
127 {
128 return Some(id);
129 }
130 }
131
132 default_fallback_selection(ch, fonts, db)
133 },
134 );
135 let options = usvg::Options {
136 font_resolver: usvg::FontResolver {
137 select_font: font_resolver,
138 select_fallback: fallback_selection,
139 },
140 ..Default::default()
141 };
142 Self {
143 asset_source,
144 usvg_options: Arc::new(options),
145 }
146 }
147
148 /// Renders the given bytes into an image buffer.
149 pub fn render_single_frame(
150 &self,
151 bytes: &[u8],
152 scale_factor: f32,
153 ) -> Result<Arc<RenderImage>, usvg::Error> {
154 self.render_pixmap(
155 bytes,
156 SvgSize::ScaleFactor(scale_factor * SMOOTH_SVG_SCALE_FACTOR),
157 )
158 .map(|pixmap| {
159 let mut buffer =
160 image::ImageBuffer::from_raw(pixmap.width(), pixmap.height(), pixmap.take())
161 .unwrap();
162
163 for pixel in buffer.chunks_exact_mut(4) {
164 swap_rgba_pa_to_bgra(pixel);
165 }
166
167 let mut image = RenderImage::new(SmallVec::from_const([Frame::new(buffer)]));
168 image.scale_factor = SMOOTH_SVG_SCALE_FACTOR;
169 Arc::new(image)
170 })
171 }
172
173 pub(crate) fn render_alpha_mask(
174 &self,
175 params: &RenderSvgParams,
176 bytes: Option<&[u8]>,
177 ) -> Result<Option<(Size<DevicePixels>, Vec<u8>)>> {
178 anyhow::ensure!(!params.size.is_zero(), "can't render at a zero size");
179
180 let render_pixmap = |bytes| {
181 let pixmap = self.render_pixmap(bytes, SvgSize::Size(params.size))?;
182
183 // Convert the pixmap's pixels into an alpha mask.
184 let size = Size::new(
185 DevicePixels(pixmap.width() as i32),
186 DevicePixels(pixmap.height() as i32),
187 );
188 let alpha_mask = pixmap
189 .pixels()
190 .iter()
191 .map(|p| p.alpha())
192 .collect::<Vec<_>>();
193
194 Ok(Some((size, alpha_mask)))
195 };
196
197 if let Some(bytes) = bytes {
198 render_pixmap(bytes)
199 } else if let Some(bytes) = self.asset_source.load(¶ms.path)? {
200 render_pixmap(&bytes)
201 } else {
202 Ok(None)
203 }
204 }
205
206 fn render_pixmap(&self, bytes: &[u8], size: SvgSize) -> Result<Pixmap, usvg::Error> {
207 let tree = usvg::Tree::from_data(bytes, &self.usvg_options)?;
208 let svg_size = tree.size();
209 let scale = match size {
210 SvgSize::Size(size) => size.width.0 as f32 / svg_size.width(),
211 SvgSize::ScaleFactor(scale) => scale,
212 };
213
214 // Render the SVG to a pixmap with the specified width and height.
215 let mut pixmap = resvg::tiny_skia::Pixmap::new(
216 (svg_size.width() * scale) as u32,
217 (svg_size.height() * scale) as u32,
218 )
219 .ok_or(usvg::Error::InvalidSize)?;
220
221 let transform = resvg::tiny_skia::Transform::from_scale(scale, scale);
222
223 resvg::render(&tree, transform, &mut pixmap.as_mut());
224
225 Ok(pixmap)
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 const IBM_PLEX_REGULAR: &[u8] =
234 include_bytes!("../../../assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf");
235 const LILEX_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/lilex/Lilex-Regular.ttf");
236
237 #[test]
238 fn test_is_emoji_presentation() {
239 let cases = [
240 ("a", false),
241 ("Z", false),
242 ("1", false),
243 ("#", false),
244 ("*", false),
245 ("ζΌ’", false),
246 ("δΈ", false),
247 ("γ«", false),
248 ("Β©", false),
249 ("β₯", false),
250 ("π", true),
251 ("β
", true),
252 ("πΊπΈ", true),
253 // SVG fallback is not cluster-aware yet
254 ("Β©οΈ", false),
255 ("β₯οΈ", false),
256 ("1οΈβ£", false),
257 ];
258 for (s, expected) in cases {
259 assert_eq!(
260 is_emoji_presentation(s.chars().next().unwrap()),
261 expected,
262 "for char {:?}",
263 s
264 );
265 }
266 }
267
268 #[test]
269 fn test_select_emoji_font_skips_family_without_glyph() {
270 let mut db = usvg::fontdb::Database::new();
271
272 db.load_font_data(IBM_PLEX_REGULAR.to_vec());
273 db.load_font_data(LILEX_REGULAR.to_vec());
274
275 let ibm_plex_sans = db
276 .query(&usvg::fontdb::Query {
277 families: &[usvg::fontdb::Family::Name("IBM Plex Sans")],
278 weight: usvg::fontdb::Weight(400),
279 stretch: usvg::fontdb::Stretch::Normal,
280 style: usvg::fontdb::Style::Normal,
281 })
282 .unwrap();
283 let lilex = db
284 .query(&usvg::fontdb::Query {
285 families: &[usvg::fontdb::Family::Name("Lilex")],
286 weight: usvg::fontdb::Weight(400),
287 stretch: usvg::fontdb::Stretch::Normal,
288 style: usvg::fontdb::Style::Normal,
289 })
290 .unwrap();
291 let selected = select_emoji_font('β', &[], &db, &["IBM Plex Sans", "Lilex"]).unwrap();
292
293 assert_eq!(selected, lilex);
294 assert!(!font_has_char(&db, ibm_plex_sans, 'β'));
295 assert!(font_has_char(&db, selected, 'β'));
296 }
297}