Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
526 lines
18 KiB
Rust
526 lines
18 KiB
Rust
use crate::{
|
|
AssetSource, DevicePixels, IsZero, RenderImage, Result, SharedString, Size,
|
|
swap_rgba_pa_to_bgra,
|
|
};
|
|
use image::Frame;
|
|
use resvg::tiny_skia::Pixmap;
|
|
use smallvec::SmallVec;
|
|
use std::{
|
|
hash::Hash,
|
|
sync::{Arc, LazyLock, OnceLock},
|
|
};
|
|
|
|
#[cfg(target_os = "macos")]
|
|
const EMOJI_FONT_FAMILIES: &[&str] = &["Apple Color Emoji", ".AppleColorEmojiUI"];
|
|
|
|
#[cfg(target_os = "windows")]
|
|
const EMOJI_FONT_FAMILIES: &[&str] = &["Segoe UI Emoji", "Segoe UI Symbol"];
|
|
|
|
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
|
const EMOJI_FONT_FAMILIES: &[&str] = &[
|
|
"Noto Color Emoji",
|
|
"Emoji One",
|
|
"Twitter Color Emoji",
|
|
"JoyPixels",
|
|
];
|
|
|
|
#[cfg(not(any(
|
|
target_os = "macos",
|
|
target_os = "windows",
|
|
target_os = "linux",
|
|
target_os = "freebsd",
|
|
)))]
|
|
const EMOJI_FONT_FAMILIES: &[&str] = &[];
|
|
|
|
fn is_emoji_presentation(c: char) -> bool {
|
|
static EMOJI_PRESENTATION_REGEX: LazyLock<regex::Regex> =
|
|
LazyLock::new(|| regex::Regex::new("\\p{Emoji_Presentation}").unwrap());
|
|
let mut buf = [0u8; 4];
|
|
EMOJI_PRESENTATION_REGEX.is_match(c.encode_utf8(&mut buf))
|
|
}
|
|
|
|
fn font_has_char(db: &usvg::fontdb::Database, id: usvg::fontdb::ID, ch: char) -> bool {
|
|
db.with_face_data(id, |font_data, face_index| {
|
|
ttf_parser::Face::parse(font_data, face_index)
|
|
.ok()
|
|
.and_then(|face| face.glyph_index(ch))
|
|
.is_some()
|
|
})
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn select_emoji_font(
|
|
ch: char,
|
|
fonts: &[usvg::fontdb::ID],
|
|
db: &usvg::fontdb::Database,
|
|
families: &[&str],
|
|
) -> Option<usvg::fontdb::ID> {
|
|
for family_name in families {
|
|
let query = usvg::fontdb::Query {
|
|
families: &[usvg::fontdb::Family::Name(family_name)],
|
|
weight: usvg::fontdb::Weight(400),
|
|
stretch: usvg::fontdb::Stretch::Normal,
|
|
style: usvg::fontdb::Style::Normal,
|
|
};
|
|
|
|
let Some(id) = db.query(&query) else {
|
|
continue;
|
|
};
|
|
|
|
if fonts.contains(&id) || !font_has_char(db, id, ch) {
|
|
continue;
|
|
}
|
|
|
|
return Some(id);
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
/// When rendering SVGs, we render them at twice the size to get a higher-quality result.
|
|
pub const SMOOTH_SVG_SCALE_FACTOR: f32 = 2.;
|
|
|
|
#[derive(Clone, PartialEq, Hash, Eq)]
|
|
#[expect(missing_docs)]
|
|
pub struct RenderSvgParams {
|
|
pub path: SharedString,
|
|
pub size: Size<DevicePixels>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
/// A struct holding everything necessary to render SVGs.
|
|
pub struct SvgRenderer {
|
|
asset_source: Arc<dyn AssetSource>,
|
|
usvg_options: Arc<usvg::Options<'static>>,
|
|
}
|
|
|
|
/// A parsed SVG document that can be rasterized at any scale.
|
|
///
|
|
/// Produced by [`SvgRenderer::parse_svg`] and rasterized by
|
|
/// [`SvgRenderer::render_parsed`]. Parsing resolves fonts and converts text
|
|
/// to paths, so callers that need to rasterize the same SVG at multiple
|
|
/// scales should retain this value to avoid re-paying the parse cost.
|
|
pub struct ParsedSvg(usvg::Tree);
|
|
|
|
/// The size in which to rasterize the SVG.
|
|
#[derive(Clone, Copy)]
|
|
pub enum SvgSize {
|
|
/// A width in device pixels. The SVG retains its aspect ratio.
|
|
Size(Size<DevicePixels>),
|
|
/// An exact width and height in device pixels.
|
|
ExactSize(Size<DevicePixels>),
|
|
/// A logical scaling factor to apply to the size provided by the SVG.
|
|
ScaleFactor(f32),
|
|
}
|
|
|
|
impl From<f32> for SvgSize {
|
|
fn from(scale_factor: f32) -> Self {
|
|
Self::ScaleFactor(scale_factor)
|
|
}
|
|
}
|
|
|
|
impl SvgRenderer {
|
|
/// Creates a new SVG renderer with the provided asset source.
|
|
pub fn new(asset_source: Arc<dyn AssetSource>) -> Self {
|
|
static SYSTEM_FONT_DB: LazyLock<Arc<usvg::fontdb::Database>> = LazyLock::new(|| {
|
|
let mut db = usvg::fontdb::Database::new();
|
|
db.load_system_fonts();
|
|
Arc::new(db)
|
|
});
|
|
|
|
// Build the enriched font DB lazily on first SVG render rather than
|
|
// eagerly at construction time. This avoids the expensive deep-clone
|
|
// of the system font database for code paths that never render SVGs
|
|
// (e.g. tests).
|
|
let enriched_fontdb: Arc<OnceLock<Arc<usvg::fontdb::Database>>> = Arc::new(OnceLock::new());
|
|
|
|
let default_font_resolver = usvg::FontResolver::default_font_selector();
|
|
let font_resolver = Box::new({
|
|
let asset_source = asset_source.clone();
|
|
move |font: &usvg::Font, db: &mut Arc<usvg::fontdb::Database>| {
|
|
if db.is_empty() {
|
|
let fontdb = enriched_fontdb.get_or_init(|| {
|
|
let mut db = (**SYSTEM_FONT_DB).clone();
|
|
load_bundled_fonts(&*asset_source, &mut db);
|
|
fix_generic_font_families(&mut db);
|
|
Arc::new(db)
|
|
});
|
|
*db = fontdb.clone();
|
|
}
|
|
if let Some(id) = default_font_resolver(font, db) {
|
|
return Some(id);
|
|
}
|
|
// fontdb doesn't recognize CSS system font keywords like "system-ui"
|
|
// or "ui-sans-serif", so fall back to sans-serif before any face.
|
|
let sans_query = usvg::fontdb::Query {
|
|
families: &[usvg::fontdb::Family::SansSerif],
|
|
..Default::default()
|
|
};
|
|
db.query(&sans_query)
|
|
.or_else(|| db.faces().next().map(|f| f.id))
|
|
}
|
|
});
|
|
let default_fallback_selection = usvg::FontResolver::default_fallback_selector();
|
|
let fallback_selection = Box::new(
|
|
move |ch: char, fonts: &[usvg::fontdb::ID], db: &mut Arc<usvg::fontdb::Database>| {
|
|
if is_emoji_presentation(ch) {
|
|
if let Some(id) = select_emoji_font(ch, fonts, db.as_ref(), EMOJI_FONT_FAMILIES)
|
|
{
|
|
return Some(id);
|
|
}
|
|
}
|
|
|
|
default_fallback_selection(ch, fonts, db)
|
|
},
|
|
);
|
|
let options = usvg::Options {
|
|
font_resolver: usvg::FontResolver {
|
|
select_font: font_resolver,
|
|
select_fallback: fallback_selection,
|
|
},
|
|
..Default::default()
|
|
};
|
|
Self {
|
|
asset_source,
|
|
usvg_options: Arc::new(options),
|
|
}
|
|
}
|
|
|
|
/// Parses SVG data into a [`ParsedSvg`] that can be rasterized at any scale.
|
|
pub fn parse_svg(&self, bytes: &[u8]) -> Result<ParsedSvg, usvg::Error> {
|
|
usvg::Tree::from_data(bytes, &self.usvg_options).map(ParsedSvg)
|
|
}
|
|
|
|
/// Rasterizes a previously parsed SVG into an image buffer.
|
|
pub fn render_parsed(
|
|
&self,
|
|
svg: &ParsedSvg,
|
|
size: impl Into<SvgSize>,
|
|
) -> Result<Arc<RenderImage>, usvg::Error> {
|
|
let (size, image_scale_factor) = match size.into() {
|
|
SvgSize::Size(size) => (SvgSize::Size(size), 1.0),
|
|
SvgSize::ExactSize(size) => (SvgSize::ExactSize(size), 1.0),
|
|
SvgSize::ScaleFactor(scale_factor) => (
|
|
SvgSize::ScaleFactor(scale_factor * SMOOTH_SVG_SCALE_FACTOR),
|
|
SMOOTH_SVG_SCALE_FACTOR,
|
|
),
|
|
};
|
|
let pixmap = rasterize_tree(&svg.0, size)?;
|
|
let mut buffer =
|
|
image::ImageBuffer::from_raw(pixmap.width(), pixmap.height(), pixmap.take()).unwrap();
|
|
|
|
for pixel in buffer.chunks_exact_mut(4) {
|
|
swap_rgba_pa_to_bgra(pixel);
|
|
}
|
|
|
|
let mut image = RenderImage::new(SmallVec::from_const([Frame::new(buffer)]));
|
|
image.scale_factor = image_scale_factor;
|
|
Ok(Arc::new(image))
|
|
}
|
|
|
|
/// Renders the given bytes into an image buffer.
|
|
pub fn render_single_frame(
|
|
&self,
|
|
bytes: &[u8],
|
|
scale_factor: f32,
|
|
) -> Result<Arc<RenderImage>, usvg::Error> {
|
|
let svg = self.parse_svg(bytes)?;
|
|
self.render_parsed(&svg, scale_factor)
|
|
}
|
|
|
|
pub(crate) fn render_alpha_mask(
|
|
&self,
|
|
params: &RenderSvgParams,
|
|
bytes: Option<&[u8]>,
|
|
) -> Result<Option<(Size<DevicePixels>, Vec<u8>)>> {
|
|
anyhow::ensure!(!params.size.is_zero(), "can't render at a zero size");
|
|
|
|
let render_pixmap = |bytes| {
|
|
let pixmap = self.render_pixmap(bytes, SvgSize::Size(params.size))?;
|
|
|
|
// Convert the pixmap's pixels into an alpha mask.
|
|
let size = Size::new(
|
|
DevicePixels(pixmap.width() as i32),
|
|
DevicePixels(pixmap.height() as i32),
|
|
);
|
|
let alpha_mask = pixmap
|
|
.pixels()
|
|
.iter()
|
|
.map(|p| p.alpha())
|
|
.collect::<Vec<_>>();
|
|
|
|
Ok(Some((size, alpha_mask)))
|
|
};
|
|
|
|
if let Some(bytes) = bytes {
|
|
render_pixmap(bytes)
|
|
} else if let Some(bytes) = self.asset_source.load(¶ms.path)? {
|
|
render_pixmap(&bytes)
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
fn render_pixmap(&self, bytes: &[u8], size: SvgSize) -> Result<Pixmap, usvg::Error> {
|
|
let tree = usvg::Tree::from_data(bytes, &self.usvg_options)?;
|
|
rasterize_tree(&tree, size)
|
|
}
|
|
}
|
|
|
|
fn rasterize_tree(tree: &usvg::Tree, size: SvgSize) -> Result<Pixmap, usvg::Error> {
|
|
// Cap the size of the rendered pixmap to avoid texture allocation panics
|
|
// Related issue: #56466
|
|
const MAX_SIZE: f32 = 8192.0;
|
|
|
|
let svg_size = tree.size();
|
|
let (mut width, mut height) = match size {
|
|
SvgSize::Size(size) => {
|
|
let scale = i32::from(size.width) as f32 / svg_size.width();
|
|
(svg_size.width() * scale, svg_size.height() * scale)
|
|
}
|
|
SvgSize::ExactSize(size) => (i32::from(size.width) as f32, i32::from(size.height) as f32),
|
|
SvgSize::ScaleFactor(scale) => (svg_size.width() * scale, svg_size.height() * scale),
|
|
};
|
|
|
|
if width > MAX_SIZE {
|
|
log::warn!("Attempted to render pixmap where width ({width}) > MAX_SIZE ({MAX_SIZE})");
|
|
}
|
|
if height > MAX_SIZE {
|
|
log::warn!("Attempted to render pixmap where height ({height}) > MAX_SIZE ({MAX_SIZE})");
|
|
}
|
|
let scale = (MAX_SIZE / width).min(MAX_SIZE / height).min(1.0);
|
|
width *= scale;
|
|
height *= scale;
|
|
|
|
// Render the SVG to a pixmap with the specified width and height.
|
|
let mut pixmap = resvg::tiny_skia::Pixmap::new(width as u32, height as u32)
|
|
.ok_or(usvg::Error::InvalidSize)?;
|
|
|
|
let transform = resvg::tiny_skia::Transform::from_scale(
|
|
width / svg_size.width(),
|
|
height / svg_size.height(),
|
|
);
|
|
|
|
resvg::render(tree, transform, &mut pixmap.as_mut());
|
|
|
|
Ok(pixmap)
|
|
}
|
|
|
|
fn load_bundled_fonts(asset_source: &dyn AssetSource, db: &mut usvg::fontdb::Database) {
|
|
let font_paths = [
|
|
"fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf",
|
|
"fonts/lilex/Lilex-Regular.ttf",
|
|
];
|
|
for path in font_paths {
|
|
match asset_source.load(path) {
|
|
Ok(Some(data)) => db.load_font_data(data.into_owned()),
|
|
Ok(None) => log::warn!("Bundled font not found: {path}"),
|
|
Err(error) => log::warn!("Failed to load bundled font {path}: {error}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
// fontdb defaults generic families to Microsoft fonts ("Arial", "Times New Roman")
|
|
// which aren't installed on most Linux systems. fontconfig normally overrides these,
|
|
// but when it fails the defaults remain and all generic family queries return None.
|
|
fn fix_generic_font_families(db: &mut usvg::fontdb::Database) {
|
|
use usvg::fontdb::{Family, Query};
|
|
|
|
let families_and_fallbacks: &[(Family<'_>, &str)] = &[
|
|
(Family::SansSerif, "IBM Plex Sans"),
|
|
// No serif font bundled; use sans-serif as best available fallback.
|
|
(Family::Serif, "IBM Plex Sans"),
|
|
(Family::Monospace, "Lilex"),
|
|
(Family::Cursive, "IBM Plex Sans"),
|
|
(Family::Fantasy, "IBM Plex Sans"),
|
|
];
|
|
|
|
for (family, fallback_name) in families_and_fallbacks {
|
|
let query = Query {
|
|
families: &[*family],
|
|
..Default::default()
|
|
};
|
|
if db.query(&query).is_none() {
|
|
match family {
|
|
Family::SansSerif => db.set_sans_serif_family(*fallback_name),
|
|
Family::Serif => db.set_serif_family(*fallback_name),
|
|
Family::Monospace => db.set_monospace_family(*fallback_name),
|
|
Family::Cursive => db.set_cursive_family(*fallback_name),
|
|
Family::Fantasy => db.set_fantasy_family(*fallback_name),
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use usvg::fontdb::{Database, Family, Query};
|
|
|
|
const IBM_PLEX_REGULAR: &[u8] =
|
|
include_bytes!("../../../assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf");
|
|
const LILEX_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/lilex/Lilex-Regular.ttf");
|
|
|
|
#[test]
|
|
fn renders_parsed_svg_at_requested_size() -> Result<()> {
|
|
let renderer = SvgRenderer::new(Arc::new(()));
|
|
let svg = renderer.parse_svg(
|
|
br#"<svg xmlns="http://www.w3.org/2000/svg" width="24pt" height="12pt"></svg>"#,
|
|
)?;
|
|
let requested_size = Size::new(DevicePixels(24), DevicePixels(12));
|
|
let image = renderer.render_parsed(&svg, SvgSize::ExactSize(requested_size))?;
|
|
|
|
assert_eq!(image.size(0), requested_size);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn preserves_aspect_ratio_for_width_constrained_size() -> Result<()> {
|
|
let renderer = SvgRenderer::new(Arc::new(()));
|
|
let svg = renderer.parse_svg(
|
|
br#"<svg xmlns="http://www.w3.org/2000/svg" width="24pt" height="12pt"></svg>"#,
|
|
)?;
|
|
let image = renderer.render_parsed(
|
|
&svg,
|
|
SvgSize::Size(Size::new(DevicePixels(24), DevicePixels(24))),
|
|
)?;
|
|
|
|
assert_eq!(image.size(0), Size::new(DevicePixels(24), DevicePixels(12)));
|
|
Ok(())
|
|
}
|
|
|
|
fn db_with_bundled_fonts() -> Database {
|
|
let mut db = Database::new();
|
|
db.load_font_data(IBM_PLEX_REGULAR.to_vec());
|
|
db.load_font_data(LILEX_REGULAR.to_vec());
|
|
db
|
|
}
|
|
|
|
#[test]
|
|
fn text_with_split_glyph_clusters_in_mixed_fonts_does_not_panic() {
|
|
let mut db = Database::new();
|
|
db.load_font_data(IBM_PLEX_REGULAR.to_vec());
|
|
db.load_font_data(LILEX_REGULAR.to_vec());
|
|
let options = usvg::Options {
|
|
fontdb: std::sync::Arc::new(db),
|
|
..Default::default()
|
|
};
|
|
|
|
// A base letter followed by a stack of combining marks. Under HarfBuzz's
|
|
// default cluster merging every mark glyph shares the base's byte index,
|
|
// which is the "glyph splitting" condition that triggered the panic. The
|
|
// chunk must use two different fonts so the buggy merge path runs.
|
|
let zalgo = "e\u{0301}\u{0302}\u{0303}\u{0304}\u{0306}\u{0307}\u{0308}\u{030a}";
|
|
let svg = format!(
|
|
r#"<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg"><text font-family="Lilex" font-size="32">{zalgo}<tspan font-family="IBM Plex Sans">{zalgo}</tspan></text></svg>"#
|
|
);
|
|
|
|
// Before the fix this aborts via panic with a message like
|
|
// "removal index (is 5) should be < len (is 5)".
|
|
usvg::Tree::from_data(svg.as_bytes(), &options)
|
|
.expect("SVG with mixed-font text should parse");
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_emoji_presentation() {
|
|
let cases = [
|
|
("a", false),
|
|
("Z", false),
|
|
("1", false),
|
|
("#", false),
|
|
("*", false),
|
|
("漢", false),
|
|
("中", false),
|
|
("カ", false),
|
|
("©", false),
|
|
("♥", false),
|
|
("😀", true),
|
|
("✅", true),
|
|
("🇺🇸", true),
|
|
// SVG fallback is not cluster-aware yet
|
|
("©️", false),
|
|
("♥️", false),
|
|
("1️⃣", false),
|
|
];
|
|
for (s, expected) in cases {
|
|
assert_eq!(
|
|
is_emoji_presentation(s.chars().next().unwrap()),
|
|
expected,
|
|
"for char {:?}",
|
|
s
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn fix_generic_font_families_sets_all_families() {
|
|
let mut db = db_with_bundled_fonts();
|
|
fix_generic_font_families(&mut db);
|
|
|
|
let families = [
|
|
Family::SansSerif,
|
|
Family::Serif,
|
|
Family::Monospace,
|
|
Family::Cursive,
|
|
Family::Fantasy,
|
|
];
|
|
|
|
for family in families {
|
|
let query = Query {
|
|
families: &[family],
|
|
..Default::default()
|
|
};
|
|
assert!(
|
|
db.query(&query).is_some(),
|
|
"Expected generic family {family:?} to resolve after fix_generic_font_families"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_select_emoji_font_skips_family_without_glyph() {
|
|
let mut db = db_with_bundled_fonts();
|
|
|
|
let ibm_plex_sans = db
|
|
.query(&usvg::fontdb::Query {
|
|
families: &[usvg::fontdb::Family::Name("IBM Plex Sans")],
|
|
weight: usvg::fontdb::Weight(400),
|
|
stretch: usvg::fontdb::Stretch::Normal,
|
|
style: usvg::fontdb::Style::Normal,
|
|
})
|
|
.unwrap();
|
|
let lilex = db
|
|
.query(&usvg::fontdb::Query {
|
|
families: &[usvg::fontdb::Family::Name("Lilex")],
|
|
weight: usvg::fontdb::Weight(400),
|
|
stretch: usvg::fontdb::Stretch::Normal,
|
|
style: usvg::fontdb::Style::Normal,
|
|
})
|
|
.unwrap();
|
|
let selected = select_emoji_font('│', &[], &db, &["IBM Plex Sans", "Lilex"]).unwrap();
|
|
|
|
assert_eq!(selected, lilex);
|
|
assert!(!font_has_char(&db, ibm_plex_sans, '│'));
|
|
assert!(font_has_char(&db, selected, '│'));
|
|
}
|
|
|
|
#[test]
|
|
fn fix_generic_font_families_monospace_resolves_to_lilex() {
|
|
let mut db = db_with_bundled_fonts();
|
|
fix_generic_font_families(&mut db);
|
|
|
|
let query = Query {
|
|
families: &[Family::Monospace],
|
|
..Default::default()
|
|
};
|
|
let id = db.query(&query).expect("Monospace should resolve");
|
|
let face = db.face(id).expect("Face should exist");
|
|
assert!(
|
|
face.families.iter().any(|(name, _)| name.contains("Lilex")),
|
|
"Monospace should map to Lilex, got {:?}",
|
|
face.families
|
|
);
|
|
}
|
|
}
|