diff --git a/.github/copyright.sh b/.github/copyright.sh index b2d899024..7bfc8d7cf 100644 --- a/.github/copyright.sh +++ b/.github/copyright.sh @@ -7,7 +7,8 @@ # -g "!src/special_directory" # Check all the standard Rust source files -output=$(rg "^// Copyright (19|20)[\d]{2} (.+ and )?the Resvg Authors( and .+)?$\n^// SPDX-License-Identifier: Apache-2\.0 OR MIT$\n\n" --files-without-match --multiline -g "*.{rs,c,cpp,h}" .) +# Exclude vendored fontdb (has its own copyright from original author) +output=$(rg "^// Copyright (19|20)[\d]{2} (.+ and )?the Resvg Authors( and .+)?$\n^// SPDX-License-Identifier: Apache-2\.0 OR MIT$\n\n" --files-without-match --multiline -g "*.{rs,c,cpp,h}" -g "!crates/fontdb/*" .) if [ -n "$output" ]; then echo -e "The following files lack the correct copyright header:\n" diff --git a/Cargo.lock b/Cargo.lock index d6d44e797..42ba3da97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -58,6 +58,20 @@ name = "bytemuck" version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "byteorder-lite" @@ -158,6 +172,15 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +[[package]] +name = "font-types" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e4d2d0cf79d38430cc9dc9aadec84774bff2e1ba30ae2bf6c16cfce9385a23" +dependencies = [ + "bytemuck", +] + [[package]] name = "fontconfig-parser" version = "0.5.8" @@ -170,15 +193,13 @@ dependencies = [ [[package]] name = "fontdb" version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" dependencies = [ "fontconfig-parser", "log", "memmap2", + "skrifa", "slotmap", "tinyvec", - "ttf-parser", ] [[package]] @@ -197,6 +218,19 @@ dependencies = [ "weezl", ] +[[package]] +name = "harfrust" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f9f40651a03bc0f7316bd75267ff5767e93017ef3cfffe76c6aa7252cc5a31c" +dependencies = [ + "bitflags 2.10.0", + "bytemuck", + "core_maths", + "read-fonts", + "smallvec", +] + [[package]] name = "image-webp" version = "0.2.4" @@ -352,12 +386,30 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "proc-macro2" +version = "1.0.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +dependencies = [ + "unicode-ident", +] + [[package]] name = "quick-error" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quote" +version = "1.0.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +dependencies = [ + "proc-macro2", +] + [[package]] name = "rand" version = "0.6.5" @@ -473,6 +525,17 @@ dependencies = [ "rand_core 0.3.1", ] +[[package]] +name = "read-fonts" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5" +dependencies = [ + "bytemuck", + "core_maths", + "font-types", +] + [[package]] name = "resvg" version = "0.46.0" @@ -522,24 +585,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "rustybuzz" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" -dependencies = [ - "bitflags 2.10.0", - "bytemuck", - "core_maths", - "log", - "smallvec", - "ttf-parser", - "unicode-bidi-mirroring", - "unicode-ccc", - "unicode-properties", - "unicode-script", -] - [[package]] name = "simd-adler32" version = "0.3.8" @@ -567,6 +612,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" +[[package]] +name = "skrifa" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbdfe3d2475fbd7ddd1f3e5cf8288a30eb3e5f95832829570cd88115a7434ac" +dependencies = [ + "bytemuck", + "read-fonts", +] + [[package]] name = "slotmap" version = "1.1.1" @@ -601,6 +656,17 @@ dependencies = [ "siphasher 1.0.1", ] +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "tiny-skia" version = "0.11.4" @@ -642,15 +708,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" -[[package]] -name = "ttf-parser" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" -dependencies = [ - "core_maths", -] - [[package]] name = "unicode-bidi" version = "0.3.18" @@ -658,22 +715,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] -name = "unicode-bidi-mirroring" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" - -[[package]] -name = "unicode-ccc" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" - -[[package]] -name = "unicode-properties" -version = "0.1.4" +name = "unicode-ident" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" [[package]] name = "unicode-script" @@ -695,19 +740,19 @@ dependencies = [ "data-url", "flate2", "fontdb", + "harfrust", "imagesize", "kurbo", "log", "once_cell", "pico-args", "roxmltree 0.21.1", - "rustybuzz", "simplecss", "siphasher 1.0.1", + "skrifa", "strict-num", "svgtypes", "tiny-skia-path", - "ttf-parser", "unicode-bidi", "unicode-script", "unicode-vo", diff --git a/Cargo.toml b/Cargo.toml index 1bfecbc73..17c7baeb4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "crates/c-api", + "crates/fontdb", "crates/resvg", "crates/usvg", "crates/usvg/codegen", diff --git a/crates/fontdb/Cargo.toml b/crates/fontdb/Cargo.toml new file mode 100644 index 000000000..cbe2ba786 --- /dev/null +++ b/crates/fontdb/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "fontdb" +version = "0.23.0" +authors = ["Yevhenii Reizner "] +edition = "2024" +rust-version = "1.87.0" +license = "MIT" +description = "A simple, in-memory font database with CSS-like queries. Vendored for resvg with skrifa backend." +repository = "https://github.com/linebender/resvg" +documentation = "https://docs.rs/fontdb/" +keywords = ["font", "db", "css", "truetype", "opentype"] +categories = ["text-processing"] + +[dependencies] +slotmap = "1.0" +tinyvec = { version = "1.8", features = ["alloc"] } +skrifa = "0.40" +log = "0.4" + +memmap2 = { version = "0.9", optional = true } +fontconfig-parser = { version = "0.5.8", optional = true } + +[features] +default = ["std", "fs", "system-fonts", "memmap"] +# Enables standard library support (always on for this vendored version). +std = [] +# Enables file system operations (loading fonts from files/directories). +fs = ["std"] +# Enables system fonts loading. +system-fonts = ["fs", "fontconfig"] +# Enables fontconfig support on Linux. +fontconfig = ["fontconfig-parser"] +# Enables font files memory mapping for faster loading. +memmap = ["fs", "memmap2"] diff --git a/crates/fontdb/src/lib.rs b/crates/fontdb/src/lib.rs new file mode 100644 index 000000000..f88b764ab --- /dev/null +++ b/crates/fontdb/src/lib.rs @@ -0,0 +1,1359 @@ +// Copyright 2020 Yevhenii Reizner (original fontdb, MIT licensed) +// Copyright 2026 the Resvg Authors (modifications) +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Vendored and modified version of fontdb 0.23.0 that uses skrifa instead of ttf-parser. +//! +//! Original: (MIT licensed) +//! +//! # Features +//! +//! - The database can load fonts from files, directories and raw data (`Vec`). +//! - The database can match a font using CSS-like queries. See `Database::query`. +//! - The database can try to load system fonts. +//! Currently, this is implemented by scanning predefined directories. +//! The library does not interact with the system API. +//! - Provides a unique ID for each font face. +//! +//! # Font vs Face +//! +//! A font is a collection of font faces. Therefore, a font face is a subset of a font. +//! A simple font (*.ttf/*.otf) usually contains a single font face, +//! but a font collection (*.ttc) can contain multiple font faces. +//! +//! `fontdb` stores and matches font faces, not fonts. +//! Therefore, after loading a font collection with 5 faces (for example), the database will be populated +//! with 5 `FaceInfo` objects, all of which will be pointing to the same file or binary data. +//! +//! # Performance +//! +//! The database performance is largely limited by the storage itself. +//! Font parsing is handled by skrifa. +//! +//! # Safety +//! +//! The library relies on memory-mapped files, which is inherently unsafe. +//! But since we do not keep the files open it should be perfectly safe. +//! +//! If you would like to use a persistent memory mapping of the font files, +//! then you can use the unsafe [`Database::make_shared_face_data`] function. + +// Allow unsafe code for mmap operations (from original fontdb) +#![allow(unsafe_code)] +#![deny(missing_docs)] + +use slotmap::SlotMap; +use tinyvec::TinyVec; + +use skrifa::{FontRef, MetadataProvider, raw::FileRef, raw::TableProvider, string::StringId}; + +/// A font face language. +/// +/// Simplified version - we only need to distinguish English US for family name prioritization. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash, Default)] +pub enum Language { + /// English (United States) + EnglishUnitedStates, + /// Any other language + #[default] + Unknown, +} + +impl Language { + /// Returns the primary language tag. + pub fn primary_language(&self) -> &'static str { + match self { + Language::EnglishUnitedStates => "en", + Language::Unknown => "und", + } + } + + /// Returns the region tag. + pub fn region(&self) -> &'static str { + match self { + Language::EnglishUnitedStates => "US", + Language::Unknown => "", + } + } +} + +/// Convert from BCP-47 language tag to our Language enum +fn language_from_bcp47(tag: Option<&str>) -> Language { + match tag { + Some(t) if t.starts_with("en-US") || t == "en" => Language::EnglishUnitedStates, + _ => Language::Unknown, + } +} + +/// Selects a normal, condensed, or expanded face from a font family. +#[derive(Clone, Copy, PartialOrd, Ord, PartialEq, Eq, Debug, Hash, Default)] +pub enum Stretch { + /// 50% + UltraCondensed, + /// 62.5% + ExtraCondensed, + /// 75% + Condensed, + /// 87.5% + SemiCondensed, + /// 100% + #[default] + Normal, + /// 112.5% + SemiExpanded, + /// 125% + Expanded, + /// 150% + ExtraExpanded, + /// 200% + UltraExpanded, +} + +impl Stretch { + /// Convert to a numeric value for CSS matching calculations. + fn to_number(self) -> i32 { + match self { + Stretch::UltraCondensed => 1, + Stretch::ExtraCondensed => 2, + Stretch::Condensed => 3, + Stretch::SemiCondensed => 4, + Stretch::Normal => 5, + Stretch::SemiExpanded => 6, + Stretch::Expanded => 7, + Stretch::ExtraExpanded => 8, + Stretch::UltraExpanded => 9, + } + } +} + +/// Convert from skrifa's Stretch percentage to our Stretch enum +fn stretch_from_skrifa(s: skrifa::attribute::Stretch) -> Stretch { + let pct = s.percentage(); + if pct <= 56.25 { + Stretch::UltraCondensed + } else if pct <= 68.75 { + Stretch::ExtraCondensed + } else if pct <= 81.25 { + Stretch::Condensed + } else if pct <= 93.75 { + Stretch::SemiCondensed + } else if pct <= 106.25 { + Stretch::Normal + } else if pct <= 118.75 { + Stretch::SemiExpanded + } else if pct <= 137.5 { + Stretch::Expanded + } else if pct <= 175.0 { + Stretch::ExtraExpanded + } else { + Stretch::UltraExpanded + } +} + +/// Get the number of fonts in a font collection (TTC), or 1 for single fonts +fn fonts_in_collection(data: &[u8]) -> u32 { + match FileRef::new(data) { + Ok(FileRef::Collection(c)) => c.len(), + Ok(FileRef::Font(_)) => 1, + Err(_) => 1, + } +} + +/// A unique per database face ID. +/// +/// Since `Database` is not global/unique, we cannot guarantee that a specific ID +/// is actually from the same db instance. This is up to the caller. +/// +/// ID overflow will cause a panic, but it's highly unlikely that someone would +/// load more than 4 billion font faces. +/// +/// Because the internal representation of ID is private, The `Display` trait +/// implementation for this type only promise that unequal IDs will be displayed +/// as different strings, but does not make any guarantees about format or +/// content of the strings. +/// +/// [`KeyData`]: https://docs.rs/slotmap/latest/slotmap/struct.KeyData.html +#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd, Debug, Default)] +pub struct ID(InnerId); + +slotmap::new_key_type! { + /// Internal ID type. + struct InnerId; +} + +impl ID { + /// Creates a dummy ID. + /// + /// Should be used in tandem with [`Database::push_face_info`]. + #[inline] + pub fn dummy() -> Self { + Self(InnerId::from(slotmap::KeyData::from_ffi(core::u64::MAX))) + } +} + +impl core::fmt::Display for ID { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}", (self.0).0.as_ffi()) + } +} + +/// A list of possible font loading errors. +#[derive(Debug)] +enum LoadError { + /// A malformed font. + /// + /// Typically means that skrifa wasn't able to parse it. + MalformedFont, + /// A valid TrueType font without a valid *Family Name*. + UnnamedFont, + /// A file IO related error. + #[cfg(feature = "std")] + IoError(std::io::Error), +} + +#[cfg(feature = "std")] +impl From for LoadError { + #[inline] + fn from(e: std::io::Error) -> Self { + LoadError::IoError(e) + } +} + +impl core::fmt::Display for LoadError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + LoadError::MalformedFont => write!(f, "malformed font"), + LoadError::UnnamedFont => write!(f, "font doesn't have a family name"), + #[cfg(feature = "std")] + LoadError::IoError(e) => write!(f, "{}", e), + } + } +} + +/// A font database. +#[derive(Clone, Debug)] +pub struct Database { + faces: SlotMap, + family_serif: String, + family_sans_serif: String, + family_cursive: String, + family_fantasy: String, + family_monospace: String, +} + +impl Default for Database { + fn default() -> Self { + Self::new() + } +} + +impl Database { + /// Create a new, empty `Database`. + /// + /// Generic font families would be set to: + /// + /// - `serif` - Times New Roman + /// - `sans-serif` - Arial + /// - `cursive` - Comic Sans MS + /// - `fantasy` - Impact (Papyrus on macOS) + /// - `monospace` - Courier New + #[inline] + pub fn new() -> Self { + Database { + faces: SlotMap::with_key(), + family_serif: "Times New Roman".to_string(), + family_sans_serif: "Arial".to_string(), + family_cursive: "Comic Sans MS".to_string(), + #[cfg(not(target_os = "macos"))] + family_fantasy: "Impact".to_string(), + #[cfg(target_os = "macos")] + family_fantasy: "Papyrus".to_string(), + family_monospace: "Courier New".to_string(), + } + } + + /// Loads a font data into the `Database`. + /// + /// Will load all font faces in case of a font collection. + pub fn load_font_data(&mut self, data: Vec) { + self.load_font_source(Source::Binary(std::sync::Arc::new(data))); + } + + /// Loads a font from the given source into the `Database` and returns + /// the ID of the loaded font. + /// + /// Will load all font faces in case of a font collection. + pub fn load_font_source(&mut self, source: Source) -> TinyVec<[ID; 8]> { + let ids = source.with_data(|data| { + let n = fonts_in_collection(data); + let mut ids = TinyVec::with_capacity(n as usize); + + for index in 0..n { + match parse_face_info(source.clone(), data, index) { + Ok(mut info) => { + let id = self.faces.insert_with_key(|k| { + info.id = ID(k); + info + }); + ids.push(ID(id)); + } + Err(e) => log::warn!( + "Failed to load a font face {} from source cause {}.", + index, + e + ), + } + } + + ids + }); + + ids.unwrap_or_default() + } + + /// Backend function used by load_font_file to load font files. + #[cfg(feature = "fs")] + fn load_fonts_from_file(&mut self, path: &std::path::Path, data: &[u8]) { + let source = Source::File(path.into()); + + let n = fonts_in_collection(data); + for index in 0..n { + match parse_face_info(source.clone(), data, index) { + Ok(info) => { + self.push_face_info(info); + } + Err(e) => { + log::warn!( + "Failed to load a font face {} from '{}' cause {}.", + index, + path.display(), + e + ) + } + } + } + } + + /// Loads a font file into the `Database`. + /// + /// Will load all font faces in case of a font collection. + #[cfg(all(feature = "fs", feature = "memmap"))] + pub fn load_font_file>( + &mut self, + path: P, + ) -> Result<(), std::io::Error> { + self.load_font_file_impl(path.as_ref()) + } + + // A non-generic version. + #[cfg(all(feature = "fs", feature = "memmap"))] + fn load_font_file_impl(&mut self, path: &std::path::Path) -> Result<(), std::io::Error> { + let file = std::fs::File::open(path)?; + let data: &[u8] = unsafe { &memmap2::MmapOptions::new().map(&file)? }; + + self.load_fonts_from_file(path, data); + Ok(()) + } + + /// Loads a font file into the `Database`. + /// + /// Will load all font faces in case of a font collection. + #[cfg(all(feature = "fs", not(feature = "memmap")))] + pub fn load_font_file>( + &mut self, + path: P, + ) -> Result<(), std::io::Error> { + self.load_font_file_impl(path.as_ref()) + } + + // A non-generic version. + #[cfg(all(feature = "fs", not(feature = "memmap")))] + fn load_font_file_impl(&mut self, path: &std::path::Path) -> Result<(), std::io::Error> { + let data = std::fs::read(path)?; + + self.load_fonts_from_file(path, &data); + Ok(()) + } + + /// Loads font files from the selected directory into the `Database`. + /// + /// This method will scan directories recursively. + /// + /// Will load `ttf`, `otf`, `ttc` and `otc` fonts. + /// + /// Unlike other `load_*` methods, this one doesn't return an error. + /// It will simply skip malformed fonts and will print a warning into the log for each of them. + #[cfg(feature = "fs")] + pub fn load_fonts_dir>(&mut self, dir: P) { + self.load_fonts_dir_impl(dir.as_ref(), &mut Default::default()) + } + + #[cfg(feature = "fs")] + fn canonicalize( + &self, + path: std::path::PathBuf, + entry: std::fs::DirEntry, + seen: &mut std::collections::HashSet, + ) -> Option<(std::path::PathBuf, std::fs::FileType)> { + let file_type = entry.file_type().ok()?; + if !file_type.is_symlink() { + if !seen.is_empty() { + if seen.contains(&path) { + return None; + } + seen.insert(path.clone()); + } + + return Some((path, file_type)); + } + + if seen.is_empty() && file_type.is_dir() { + seen.reserve(8192 / std::mem::size_of::()); + + for (_, info) in self.faces.iter() { + let path = match &info.source { + Source::Binary(_) => continue, + Source::File(path) => path.to_path_buf(), + #[cfg(feature = "memmap")] + Source::SharedFile(path, _) => path.to_path_buf(), + }; + seen.insert(path); + } + } + + let stat = std::fs::metadata(&path).ok()?; + if stat.is_symlink() { + return None; + } + + let canon = std::fs::canonicalize(path).ok()?; + if seen.contains(&canon) { + return None; + } + seen.insert(canon.clone()); + Some((canon, stat.file_type())) + } + + // A non-generic version. + #[cfg(feature = "fs")] + fn load_fonts_dir_impl( + &mut self, + dir: &std::path::Path, + seen: &mut std::collections::HashSet, + ) { + let fonts_dir = match std::fs::read_dir(dir) { + Ok(dir) => dir, + Err(_) => return, + }; + + for entry in fonts_dir.flatten() { + let (path, file_type) = match self.canonicalize(entry.path(), entry, seen) { + Some(v) => v, + None => continue, + }; + + if file_type.is_file() { + match path.extension().and_then(|e| e.to_str()) { + #[rustfmt::skip] // keep extensions match as is + Some("ttf") | Some("ttc") | Some("TTF") | Some("TTC") | + Some("otf") | Some("otc") | Some("OTF") | Some("OTC") => { + if let Err(e) = self.load_font_file(&path) { + log::warn!("Failed to load '{}' cause {}.", path.display(), e); + } + }, + _ => {} + } + } else if file_type.is_dir() { + self.load_fonts_dir_impl(&path, seen); + } + } + } + + /// Attempts to load system fonts. + /// + /// Supports Windows, Linux and macOS. + /// + /// System fonts loading is a surprisingly complicated task, + /// mostly unsolvable without interacting with system libraries. + /// And since `fontdb` tries to be small and portable, this method + /// will simply scan some predefined directories. + /// Which means that fonts that are not in those directories must + /// be added manually. + #[cfg(feature = "fs")] + pub fn load_system_fonts(&mut self) { + #[cfg(target_os = "windows")] + { + let mut seen = Default::default(); + if let Some(ref system_root) = std::env::var_os("SYSTEMROOT") { + let system_root_path = std::path::Path::new(system_root); + self.load_fonts_dir_impl(&system_root_path.join("Fonts"), &mut seen); + } else { + self.load_fonts_dir_impl("C:\\Windows\\Fonts\\".as_ref(), &mut seen); + } + + if let Ok(ref home) = std::env::var("USERPROFILE") { + let home_path = std::path::Path::new(home); + self.load_fonts_dir_impl( + &home_path.join("AppData\\Local\\Microsoft\\Windows\\Fonts"), + &mut seen, + ); + self.load_fonts_dir_impl( + &home_path.join("AppData\\Roaming\\Microsoft\\Windows\\Fonts"), + &mut seen, + ); + } + } + + #[cfg(target_os = "macos")] + { + let mut seen = Default::default(); + self.load_fonts_dir_impl("/Library/Fonts".as_ref(), &mut seen); + self.load_fonts_dir_impl("/System/Library/Fonts".as_ref(), &mut seen); + // Downloadable fonts, location varies on major macOS releases + if let Ok(dir) = std::fs::read_dir("/System/Library/AssetsV2") { + for entry in dir { + let entry = match entry { + Ok(entry) => entry, + Err(_) => continue, + }; + if entry + .file_name() + .to_string_lossy() + .starts_with("com_apple_MobileAsset_Font") + { + self.load_fonts_dir_impl(&entry.path(), &mut seen); + } + } + } + self.load_fonts_dir_impl("/Network/Library/Fonts".as_ref(), &mut seen); + + if let Ok(ref home) = std::env::var("HOME") { + let home_path = std::path::Path::new(home); + self.load_fonts_dir_impl(&home_path.join("Library/Fonts"), &mut seen); + } + } + + // Redox OS. + #[cfg(target_os = "redox")] + { + let mut seen = Default::default(); + self.load_fonts_dir_impl("/ui/fonts".as_ref(), &mut seen); + } + + // Linux. + #[cfg(all(unix, not(any(target_os = "macos", target_os = "android"))))] + { + #[cfg(feature = "fontconfig")] + { + if !self.load_fontconfig() { + log::warn!("Fallback to loading from known font dir paths."); + self.load_no_fontconfig(); + } + } + + #[cfg(not(feature = "fontconfig"))] + { + self.load_no_fontconfig(); + } + } + } + + // Linux. + #[cfg(all( + unix, + feature = "fs", + not(any(target_os = "macos", target_os = "android")) + ))] + fn load_no_fontconfig(&mut self) { + let mut seen = Default::default(); + self.load_fonts_dir_impl("/usr/share/fonts/".as_ref(), &mut seen); + self.load_fonts_dir_impl("/usr/local/share/fonts/".as_ref(), &mut seen); + + if let Ok(ref home) = std::env::var("HOME") { + let home_path = std::path::Path::new(home); + self.load_fonts_dir_impl(&home_path.join(".fonts"), &mut seen); + self.load_fonts_dir_impl(&home_path.join(".local/share/fonts"), &mut seen); + } + } + + // Linux. + #[cfg(all( + unix, + feature = "fontconfig", + not(any(target_os = "macos", target_os = "android")) + ))] + fn load_fontconfig(&mut self) -> bool { + use std::path::Path; + + let mut fontconfig = fontconfig_parser::FontConfig::default(); + let home = std::env::var("HOME"); + + if let Ok(ref config_file) = std::env::var("FONTCONFIG_FILE") { + let _ = fontconfig.merge_config(Path::new(config_file)); + } else { + let xdg_config_home = if let Ok(val) = std::env::var("XDG_CONFIG_HOME") { + Some(val.into()) + } else if let Ok(ref home) = home { + // according to https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html + // $XDG_CONFIG_HOME should default to $HOME/.config if not set + Some(Path::new(home).join(".config")) + } else { + None + }; + + let read_global = match xdg_config_home { + Some(p) => fontconfig + .merge_config(&p.join("fontconfig/fonts.conf")) + .is_err(), + None => true, + }; + + if read_global { + let _ = fontconfig.merge_config(Path::new("/etc/fonts/local.conf")); + } + let _ = fontconfig.merge_config(Path::new("/etc/fonts/fonts.conf")); + } + + for fontconfig_parser::Alias { + alias, + default, + prefer, + accept, + } in fontconfig.aliases + { + let name = prefer + .get(0) + .or_else(|| accept.get(0)) + .or_else(|| default.get(0)); + + if let Some(name) = name { + match alias.to_lowercase().as_str() { + "serif" => self.set_serif_family(name), + "sans-serif" => self.set_sans_serif_family(name), + "sans serif" => self.set_sans_serif_family(name), + "monospace" => self.set_monospace_family(name), + "cursive" => self.set_cursive_family(name), + "fantasy" => self.set_fantasy_family(name), + _ => {} + } + } + } + + if fontconfig.dirs.is_empty() { + return false; + } + + let mut seen = Default::default(); + for dir in fontconfig.dirs { + let path = if dir.path.starts_with("~") { + if let Ok(ref home) = home { + Path::new(home).join(dir.path.strip_prefix("~").unwrap()) + } else { + continue; + } + } else { + dir.path + }; + self.load_fonts_dir_impl(&path, &mut seen); + } + + true + } + + /// Pushes a user-provided `FaceInfo` to the database. + /// + /// In some cases, a caller might want to ignore the font's metadata and provide their own. + /// This method doesn't parse the `source` font. + /// + /// The `id` field should be set to [`ID::dummy()`] and will be then overwritten by this method. + pub fn push_face_info(&mut self, mut info: FaceInfo) -> ID { + ID(self.faces.insert_with_key(|k| { + info.id = ID(k); + info + })) + } + + /// Removes a font face by `id` from the database. + /// + /// Returns `false` while attempting to remove a non-existing font face. + /// + /// Useful when you want to ignore some specific font face(s) + /// after loading a large directory with fonts. + /// Or a specific face from a font. + pub fn remove_face(&mut self, id: ID) { + self.faces.remove(id.0); + } + + /// Returns `true` if the `Database` contains no font faces. + #[inline] + pub fn is_empty(&self) -> bool { + self.faces.is_empty() + } + + /// Returns the number of font faces in the `Database`. + /// + /// Note that `Database` stores font faces, not fonts. + /// For example, if a caller will try to load a font collection (`*.ttc`) that contains 5 faces, + /// then the `Database` will load 5 font faces and this method will return 5, not 1. + #[inline] + pub fn len(&self) -> usize { + self.faces.len() + } + + /// Sets the family that will be used by `Family::Serif`. + pub fn set_serif_family>(&mut self, family: S) { + self.family_serif = family.into(); + } + + /// Sets the family that will be used by `Family::SansSerif`. + pub fn set_sans_serif_family>(&mut self, family: S) { + self.family_sans_serif = family.into(); + } + + /// Sets the family that will be used by `Family::Cursive`. + pub fn set_cursive_family>(&mut self, family: S) { + self.family_cursive = family.into(); + } + + /// Sets the family that will be used by `Family::Fantasy`. + pub fn set_fantasy_family>(&mut self, family: S) { + self.family_fantasy = family.into(); + } + + /// Sets the family that will be used by `Family::Monospace`. + pub fn set_monospace_family>(&mut self, family: S) { + self.family_monospace = family.into(); + } + + /// Returns the generic family name or the `Family::Name` itself. + /// + /// Generic family names should be set via `Database::set_*_family` methods. + pub fn family_name<'a>(&'a self, family: &'a Family) -> &'a str { + match family { + Family::Name(name) => name, + Family::Serif => self.family_serif.as_str(), + Family::SansSerif => self.family_sans_serif.as_str(), + Family::Cursive => self.family_cursive.as_str(), + Family::Fantasy => self.family_fantasy.as_str(), + Family::Monospace => self.family_monospace.as_str(), + } + } + + /// Performs a CSS-like query and returns the best matched font face. + pub fn query(&self, query: &Query) -> Option { + for family in query.families { + let name = self.family_name(family); + let candidates: Vec<_> = self + .faces + .iter() + .filter(|(_, face)| face.families.iter().any(|family| family.0 == name)) + .map(|(_, info)| info) + .collect(); + + if !candidates.is_empty() { + if let Some(index) = find_best_match(&candidates, query) { + return Some(candidates[index].id); + } + } + } + + None + } + + /// Returns an iterator over the internal storage. + /// + /// This can be used for manual font matching. + #[inline] + pub fn faces(&self) -> impl Iterator + '_ { + self.faces.iter().map(|(_, info)| info) + } + + /// Selects a `FaceInfo` by `id`. + /// + /// Returns `None` if a face with such ID was already removed, + /// or this ID belong to the other `Database`. + pub fn face(&self, id: ID) -> Option<&FaceInfo> { + self.faces.get(id.0) + } + + /// Returns font face storage and the face index by `ID`. + pub fn face_source(&self, id: ID) -> Option<(Source, u32)> { + self.face(id).map(|info| (info.source.clone(), info.index)) + } + + /// Executes a closure with a font's data. + /// + /// We can't return a reference to a font binary data because of lifetimes. + /// So instead, you can use this method to process font's data. + /// + /// The closure accepts raw font data and font face index. + /// + /// In case of `Source::File`, the font file will be memory mapped. + /// + /// Returns `None` when font file loading failed. + /// + /// # Example + /// + /// ```ignore + /// let is_variable = db.with_face_data(id, |font_data, face_index| { + /// let font = ttf_parser::Face::from_slice(font_data, face_index).unwrap(); + /// font.is_variable() + /// })?; + /// ``` + pub fn with_face_data(&self, id: ID, p: P) -> Option + where + P: FnOnce(&[u8], u32) -> T, + { + let (src, face_index) = self.face_source(id)?; + src.with_data(|data| p(data, face_index)) + } + + /// Makes the font data that backs the specified face id shared so that the application can + /// hold a reference to it. + /// + /// # Safety + /// + /// If the face originates from a file from disk, then the file is mapped from disk. This is unsafe as + /// another process may make changes to the file on disk, which may become visible in this process' + /// mapping and possibly cause crashes. + /// + /// If the underlying font provides multiple faces, then all faces are updated to participate in + /// the data sharing. If the face was previously marked for data sharing, then this function will + /// return a clone of the existing reference. + #[cfg(all(feature = "fs", feature = "memmap"))] + pub unsafe fn make_shared_face_data( + &mut self, + id: ID, + ) -> Option<(std::sync::Arc + Send + Sync>, u32)> { + let face_info = self.faces.get(id.0)?; + let face_index = face_info.index; + + let old_source = face_info.source.clone(); + + let (path, shared_data) = match &old_source { + Source::Binary(data) => { + return Some((data.clone(), face_index)); + } + Source::File(path) => { + let file = std::fs::File::open(path).ok()?; + // SAFETY: We immediately copy data out, not keeping the mmap alive + let shared_data = + std::sync::Arc::new(unsafe { memmap2::MmapOptions::new().map(&file).ok()? }) + as std::sync::Arc + Send + Sync>; + (path.clone(), shared_data) + } + Source::SharedFile(_, data) => { + return Some((data.clone(), face_index)); + } + }; + + let shared_source = Source::SharedFile(path.clone(), shared_data.clone()); + + self.faces.iter_mut().for_each(|(_, face)| { + if matches!(&face.source, Source::File(old_path) if old_path == &path) { + face.source = shared_source.clone(); + } + }); + + Some((shared_data, face_index)) + } + + /// Transfers ownership of shared font data back to the font database. This is the reverse operation + /// of [`Self::make_shared_face_data`]. If the font data belonging to the specified face is mapped + /// from a file on disk, then that mapping is closed and the data becomes private to the process again. + #[cfg(all(feature = "fs", feature = "memmap"))] + pub fn make_face_data_unshared(&mut self, id: ID) { + let face_info = match self.faces.get(id.0) { + Some(face_info) => face_info, + None => return, + }; + + let old_source = face_info.source.clone(); + + let shared_path = match old_source { + #[cfg(all(feature = "fs", feature = "memmap"))] + Source::SharedFile(path, _) => path, + _ => return, + }; + + let new_source = Source::File(shared_path.clone()); + + self.faces.iter_mut().for_each(|(_, face)| { + if matches!(&face.source, Source::SharedFile(path, ..) if path == &shared_path) { + face.source = new_source.clone(); + } + }); + } +} + +/// A single font face info. +/// +/// A font can have multiple faces. +/// +/// A single item of the `Database`. +#[derive(Clone, Debug)] +pub struct FaceInfo { + /// An unique ID. + pub id: ID, + + /// A font source. + /// + /// Note that multiple `FaceInfo` objects can reference the same data in case of + /// font collections, which means that they'll use the same Source. + pub source: Source, + + /// A face index in the `source`. + pub index: u32, + + /// A list of family names. + /// + /// Contains pairs of Name + Language. Where the first family is always English US, + /// unless it's missing from the font. + /// + /// Corresponds to a *Typographic Family* (ID 16) or a *Font Family* (ID 1) [name ID] + /// in a TrueType font. + /// + /// This is not an *Extended Typographic Family* or a *Full Name*. + /// Meaning it will contain _Arial_ and not _Arial Bold_. + /// + /// [name ID]: https://docs.microsoft.com/en-us/typography/opentype/spec/name#name-ids + pub families: Vec<(String, Language)>, + + /// A PostScript name. + /// + /// Corresponds to a *PostScript name* (6) [name ID] in a TrueType font. + /// + /// [name ID]: https://docs.microsoft.com/en-us/typography/opentype/spec/name#name-ids + pub post_script_name: String, + + /// A font face style. + pub style: Style, + + /// A font face weight. + pub weight: Weight, + + /// A font face stretch. + pub stretch: Stretch, + + /// Indicates that the font face is monospaced. + pub monospaced: bool, +} + +/// A font source. +/// +/// Either a raw binary data or a file path. +/// +/// Stores the whole font and not just a single face. +#[derive(Clone)] +pub enum Source { + /// A font's raw data, typically backed by a Vec. + Binary(std::sync::Arc + Sync + Send>), + + /// A font's path. + #[cfg(feature = "fs")] + File(std::path::PathBuf), + + /// A font's raw data originating from a shared file mapping. + #[cfg(all(feature = "fs", feature = "memmap"))] + SharedFile( + std::path::PathBuf, + std::sync::Arc + Sync + Send>, + ), +} + +impl core::fmt::Debug for Source { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Binary(arg0) => f + .debug_tuple("SharedBinary") + .field(&arg0.as_ref().as_ref()) + .finish(), + #[cfg(feature = "fs")] + Self::File(arg0) => f.debug_tuple("File").field(arg0).finish(), + #[cfg(all(feature = "fs", feature = "memmap"))] + Self::SharedFile(arg0, arg1) => f + .debug_tuple("SharedFile") + .field(arg0) + .field(&arg1.as_ref().as_ref()) + .finish(), + } + } +} + +impl Source { + fn with_data(&self, p: P) -> Option + where + P: FnOnce(&[u8]) -> T, + { + match &self { + #[cfg(all(feature = "fs", not(feature = "memmap")))] + Source::File(path) => { + let data = std::fs::read(path).ok()?; + + Some(p(&data)) + } + #[cfg(all(feature = "fs", feature = "memmap"))] + Source::File(path) => { + let file = std::fs::File::open(path).ok()?; + // SAFETY: Memory mapping is valid for the duration of this function call + let data = unsafe { memmap2::MmapOptions::new().map(&file).ok()? }; + + Some(p(&data)) + } + Source::Binary(data) => Some(p(data.as_ref().as_ref())), + #[cfg(all(feature = "fs", feature = "memmap"))] + Source::SharedFile(_, data) => Some(p(data.as_ref().as_ref())), + } + } +} + +/// A database query. +/// +/// Mainly used by `Database::query()`. +#[derive(Clone, Copy, Default, Debug, Eq, PartialEq, Hash)] +pub struct Query<'a> { + /// A prioritized list of font family names or generic family names. + /// + /// [font-family](https://www.w3.org/TR/2018/REC-css-fonts-3-20180920/#propdef-font-family) in CSS. + pub families: &'a [Family<'a>], + + /// Specifies the weight of glyphs in the font, their degree of blackness or stroke thickness. + /// + /// [font-weight](https://www.w3.org/TR/2018/REC-css-fonts-3-20180920/#font-weight-prop) in CSS. + pub weight: Weight, + + /// Selects a normal, condensed, or expanded face from a font family. + /// + /// [font-stretch](https://www.w3.org/TR/2018/REC-css-fonts-3-20180920/#font-stretch-prop) in CSS. + pub stretch: Stretch, + + /// Allows italic or oblique faces to be selected. + /// + /// [font-style](https://www.w3.org/TR/2018/REC-css-fonts-3-20180920/#font-style-prop) in CSS. + pub style: Style, +} + +// Enum value descriptions are from the CSS spec. +/// A [font family](https://www.w3.org/TR/2018/REC-css-fonts-3-20180920/#propdef-font-family). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Family<'a> { + /// The name of a font family of choice. + /// + /// This must be a *Typographic Family* (ID 16) or a *Family Name* (ID 1) in terms of TrueType. + /// Meaning you have to pass a family without any additional suffixes like _Bold_, _Italic_, + /// _Regular_, etc. + /// + /// Localized names are allowed. + Name(&'a str), + + /// Serif fonts represent the formal text style for a script. + Serif, + + /// Glyphs in sans-serif fonts, as the term is used in CSS, are generally low contrast + /// and have stroke endings that are plain — without any flaring, cross stroke, + /// or other ornamentation. + SansSerif, + + /// Glyphs in cursive fonts generally use a more informal script style, + /// and the result looks more like handwritten pen or brush writing than printed letterwork. + Cursive, + + /// Fantasy fonts are primarily decorative or expressive fonts that + /// contain decorative or expressive representations of characters. + Fantasy, + + /// The sole criterion of a monospace font is that all glyphs have the same fixed width. + Monospace, +} + +/// Specifies the weight of glyphs in the font, their degree of blackness or stroke thickness. +#[derive(Clone, Copy, PartialOrd, Ord, PartialEq, Eq, Debug, Hash)] +pub struct Weight(pub u16); + +impl Default for Weight { + #[inline] + fn default() -> Weight { + Weight::NORMAL + } +} + +impl Weight { + /// Thin weight (100), the thinnest value. + pub const THIN: Weight = Weight(100); + /// Extra light weight (200). + pub const EXTRA_LIGHT: Weight = Weight(200); + /// Light weight (300). + pub const LIGHT: Weight = Weight(300); + /// Normal (400). + pub const NORMAL: Weight = Weight(400); + /// Medium weight (500, higher than normal). + pub const MEDIUM: Weight = Weight(500); + /// Semibold weight (600). + pub const SEMIBOLD: Weight = Weight(600); + /// Bold weight (700). + pub const BOLD: Weight = Weight(700); + /// Extra-bold weight (800). + pub const EXTRA_BOLD: Weight = Weight(800); + /// Black weight (900), the thickest value. + pub const BLACK: Weight = Weight(900); +} + +/// Allows italic or oblique faces to be selected. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)] +pub enum Style { + /// A face that is neither italic not obliqued. + Normal, + /// A form that is generally cursive in nature. + Italic, + /// A typically-sloped version of the regular face. + Oblique, +} + +impl Default for Style { + #[inline] + fn default() -> Style { + Style::Normal + } +} + +fn parse_face_info(source: Source, data: &[u8], index: u32) -> Result { + let font = FontRef::from_index(data, index).map_err(|_| LoadError::MalformedFont)?; + let (families, post_script_name) = parse_names(&font).ok_or(LoadError::UnnamedFont)?; + let (mut style, weight, stretch) = parse_os2(&font); + let (monospaced, italic) = parse_post(&font); + + if style == Style::Normal && italic { + style = Style::Italic; + } + + Ok(FaceInfo { + id: ID::dummy(), + source, + index, + families, + post_script_name, + style, + weight, + stretch, + monospaced, + }) +} + +fn parse_names(font: &FontRef) -> Option<(Vec<(String, Language)>, String)> { + let mut families = Vec::new(); + + // Try Typographic Family (ID 16) first + for s in font.localized_strings(StringId::TYPOGRAPHIC_FAMILY_NAME) { + let lang = language_from_bcp47(s.language()); + let name: String = s.chars().collect(); + if !name.is_empty() { + families.push((name, lang)); + } + } + + // Fallback to Family Name (ID 1) + if families.is_empty() { + for s in font.localized_strings(StringId::FAMILY_NAME) { + let lang = language_from_bcp47(s.language()); + let name: String = s.chars().collect(); + if !name.is_empty() { + families.push((name, lang)); + } + } + } + + // Make English US the first one + if families.len() > 1 { + if let Some(index) = families + .iter() + .position(|f| f.1 == Language::EnglishUnitedStates) + { + if index != 0 { + families.swap(0, index); + } + } + } + + if families.is_empty() { + return None; + } + + // Get PostScript name + let post_script_name = font + .localized_strings(StringId::POSTSCRIPT_NAME) + .next() + .map(|s| s.chars().collect::()) + .unwrap_or_default(); + + Some((families, post_script_name)) +} + +fn parse_os2(font: &FontRef) -> (Style, Weight, Stretch) { + let attrs = font.attributes(); + + let style = match attrs.style { + skrifa::attribute::Style::Normal => Style::Normal, + skrifa::attribute::Style::Italic => Style::Italic, + skrifa::attribute::Style::Oblique(_) => Style::Oblique, + }; + + let weight = Weight(attrs.weight.value() as u16); + let stretch = stretch_from_skrifa(attrs.stretch); + + (style, weight, stretch) +} + +fn parse_post(font: &FontRef) -> (bool, bool) { + // Check if monospaced using skrifa's metrics + let monospaced = font + .metrics( + skrifa::instance::Size::unscaled(), + skrifa::instance::LocationRef::default(), + ) + .is_monospace; + + // Check italic angle from post table + let italic = font + .post() + .map(|post| post.italic_angle().to_f64() != 0.0) + .unwrap_or(false); + + (monospaced, italic) +} + +// https://www.w3.org/TR/2018/REC-css-fonts-3-20180920/#font-style-matching +// Based on https://github.com/servo/font-kit +#[inline(never)] +fn find_best_match(candidates: &[&FaceInfo], query: &Query) -> Option { + debug_assert!(!candidates.is_empty()); + + // Step 4. + let mut matching_set: Vec = (0..candidates.len()).collect(); + + // Step 4a (`font-stretch`). + let matches = matching_set + .iter() + .any(|&index| candidates[index].stretch == query.stretch); + let matching_stretch = if matches { + // Exact match. + query.stretch + } else if query.stretch <= Stretch::Normal { + // Closest stretch, first checking narrower values and then wider values. + let stretch = matching_set + .iter() + .filter(|&&index| candidates[index].stretch < query.stretch) + .min_by_key(|&&index| { + query.stretch.to_number() - candidates[index].stretch.to_number() + }); + + match stretch { + Some(&matching_index) => candidates[matching_index].stretch, + None => { + let matching_index = *matching_set.iter().min_by_key(|&&index| { + candidates[index].stretch.to_number() - query.stretch.to_number() + })?; + + candidates[matching_index].stretch + } + } + } else { + // Closest stretch, first checking wider values and then narrower values. + let stretch = matching_set + .iter() + .filter(|&&index| candidates[index].stretch > query.stretch) + .min_by_key(|&&index| { + candidates[index].stretch.to_number() - query.stretch.to_number() + }); + + match stretch { + Some(&matching_index) => candidates[matching_index].stretch, + None => { + let matching_index = *matching_set.iter().min_by_key(|&&index| { + query.stretch.to_number() - candidates[index].stretch.to_number() + })?; + + candidates[matching_index].stretch + } + } + }; + matching_set.retain(|&index| candidates[index].stretch == matching_stretch); + + // Step 4b (`font-style`). + let style_preference = match query.style { + Style::Italic => [Style::Italic, Style::Oblique, Style::Normal], + Style::Oblique => [Style::Oblique, Style::Italic, Style::Normal], + Style::Normal => [Style::Normal, Style::Oblique, Style::Italic], + }; + let matching_style = *style_preference.iter().find(|&query_style| { + matching_set + .iter() + .any(|&index| candidates[index].style == *query_style) + })?; + + matching_set.retain(|&index| candidates[index].style == matching_style); + + // Step 4c (`font-weight`). + // + // The spec doesn't say what to do if the weight is between 400 and 500 exclusive, so we + // just use 450 as the cutoff. + let weight = query.weight.0; + + let matching_weight = if matching_set + .iter() + .any(|&index| candidates[index].weight.0 == weight) + { + Weight(weight) + } else if (400..450).contains(&weight) + && matching_set + .iter() + .any(|&index| candidates[index].weight.0 == 500) + { + // Check 500 first. + Weight::MEDIUM + } else if (450..=500).contains(&weight) + && matching_set + .iter() + .any(|&index| candidates[index].weight.0 == 400) + { + // Check 400 first. + Weight::NORMAL + } else if weight <= 500 { + // Closest weight, first checking thinner values and then fatter ones. + let idx = matching_set + .iter() + .filter(|&&index| candidates[index].weight.0 <= weight) + .min_by_key(|&&index| weight - candidates[index].weight.0); + + match idx { + Some(&matching_index) => candidates[matching_index].weight, + None => { + let matching_index = *matching_set + .iter() + .min_by_key(|&&index| candidates[index].weight.0 - weight)?; + candidates[matching_index].weight + } + } + } else { + // Closest weight, first checking fatter values and then thinner ones. + let idx = matching_set + .iter() + .filter(|&&index| candidates[index].weight.0 >= weight) + .min_by_key(|&&index| candidates[index].weight.0 - weight); + + match idx { + Some(&matching_index) => candidates[matching_index].weight, + None => { + let matching_index = *matching_set + .iter() + .min_by_key(|&&index| weight - candidates[index].weight.0)?; + candidates[matching_index].weight + } + } + }; + matching_set.retain(|&index| candidates[index].weight == matching_weight); + + // Ignore step 4d (`font-size`). + + // Return the result. + matching_set.into_iter().next() +} diff --git a/crates/fontdb/tests/add_fonts.rs b/crates/fontdb/tests/add_fonts.rs new file mode 100644 index 000000000..0373f856a --- /dev/null +++ b/crates/fontdb/tests/add_fonts.rs @@ -0,0 +1,18 @@ +// Copyright 2020 Yevhenii Reizner (original fontdb, MIT licensed) +// Copyright 2026 the Resvg Authors (modifications) +// SPDX-License-Identifier: Apache-2.0 OR MIT + +const DEMO_TTF: &[u8] = include_bytes!("./fonts/Tuffy.ttf"); +use std::sync::Arc; + +#[test] +fn add_fonts_and_get_ids_back() { + let mut font_db = fontdb::Database::new(); + let ids = font_db.load_font_source(fontdb::Source::Binary(Arc::new(DEMO_TTF))); + + assert_eq!(ids.len(), 1); + let id = ids[0]; + + let font = font_db.face(id).unwrap(); + assert!(font.families.iter().any(|(name, _)| name == "Tuffy")); +} diff --git a/crates/fontdb/tests/fonts/LICENSE.txt b/crates/fontdb/tests/fonts/LICENSE.txt new file mode 100644 index 000000000..defced0c0 --- /dev/null +++ b/crates/fontdb/tests/fonts/LICENSE.txt @@ -0,0 +1,11 @@ +We, the copyright holders of this work, hereby release it into the +public domain. This applies worldwide. + +In case this is not legally possible, + +We grant any entity the right to use this work for any purpose, without +any conditions, unless such conditions are required by law. + +Thatcher Ulrich http://tulrich.com +Karoly Barta bartakarcsi@gmail.com +Michael Evans http://www.evertype.com diff --git a/crates/fontdb/tests/fonts/Tuffy.ttf b/crates/fontdb/tests/fonts/Tuffy.ttf new file mode 100644 index 000000000..ade553a60 Binary files /dev/null and b/crates/fontdb/tests/fonts/Tuffy.ttf differ diff --git a/crates/resvg/tests/tests/text/color-font/colrv0.png b/crates/resvg/tests/tests/text/color-font/colrv0.png index e4b090d24..087615997 100644 Binary files a/crates/resvg/tests/tests/text/color-font/colrv0.png and b/crates/resvg/tests/tests/text/color-font/colrv0.png differ diff --git a/crates/resvg/tests/tests/text/color-font/colrv1.png b/crates/resvg/tests/tests/text/color-font/colrv1.png index 7537da902..23bb63a05 100644 Binary files a/crates/resvg/tests/tests/text/color-font/colrv1.png and b/crates/resvg/tests/tests/text/color-font/colrv1.png differ diff --git a/crates/resvg/tests/tests/text/font-variation-settings/all-axes-combined.png b/crates/resvg/tests/tests/text/font-variation-settings/all-axes-combined.png index 203656bc7..c4222be24 100644 Binary files a/crates/resvg/tests/tests/text/font-variation-settings/all-axes-combined.png and b/crates/resvg/tests/tests/text/font-variation-settings/all-axes-combined.png differ diff --git a/crates/resvg/tests/tests/text/font-variation-settings/auto-font-stretch-condensed.png b/crates/resvg/tests/tests/text/font-variation-settings/auto-font-stretch-condensed.png index 9cd355480..bfa287942 100644 Binary files a/crates/resvg/tests/tests/text/font-variation-settings/auto-font-stretch-condensed.png and b/crates/resvg/tests/tests/text/font-variation-settings/auto-font-stretch-condensed.png differ diff --git a/crates/resvg/tests/tests/text/font-variation-settings/auto-font-style-oblique.png b/crates/resvg/tests/tests/text/font-variation-settings/auto-font-style-oblique.png index c07f1e09d..36cd4484f 100644 Binary files a/crates/resvg/tests/tests/text/font-variation-settings/auto-font-style-oblique.png and b/crates/resvg/tests/tests/text/font-variation-settings/auto-font-style-oblique.png differ diff --git a/crates/resvg/tests/tests/text/font-variation-settings/auto-font-weight-700.png b/crates/resvg/tests/tests/text/font-variation-settings/auto-font-weight-700.png index 43fe14e4e..90e0d815f 100644 Binary files a/crates/resvg/tests/tests/text/font-variation-settings/auto-font-weight-700.png and b/crates/resvg/tests/tests/text/font-variation-settings/auto-font-weight-700.png differ diff --git a/crates/resvg/tests/tests/text/font-variation-settings/explicit-overrides-auto.png b/crates/resvg/tests/tests/text/font-variation-settings/explicit-overrides-auto.png index 29de617c3..650ae9d86 100644 Binary files a/crates/resvg/tests/tests/text/font-variation-settings/explicit-overrides-auto.png and b/crates/resvg/tests/tests/text/font-variation-settings/explicit-overrides-auto.png differ diff --git a/crates/resvg/tests/tests/text/font-variation-settings/grad-negative.png b/crates/resvg/tests/tests/text/font-variation-settings/grad-negative.png index b9ad9103b..133a33f07 100644 Binary files a/crates/resvg/tests/tests/text/font-variation-settings/grad-negative.png and b/crates/resvg/tests/tests/text/font-variation-settings/grad-negative.png differ diff --git a/crates/resvg/tests/tests/text/font-variation-settings/multiple-axes.png b/crates/resvg/tests/tests/text/font-variation-settings/multiple-axes.png index e827e8ae9..bd0a21e00 100644 Binary files a/crates/resvg/tests/tests/text/font-variation-settings/multiple-axes.png and b/crates/resvg/tests/tests/text/font-variation-settings/multiple-axes.png differ diff --git a/crates/resvg/tests/tests/text/font-variation-settings/opsz-144.png b/crates/resvg/tests/tests/text/font-variation-settings/opsz-144.png index 9e7ddf97b..01f851888 100644 Binary files a/crates/resvg/tests/tests/text/font-variation-settings/opsz-144.png and b/crates/resvg/tests/tests/text/font-variation-settings/opsz-144.png differ diff --git a/crates/resvg/tests/tests/text/font-variation-settings/slnt-negative.png b/crates/resvg/tests/tests/text/font-variation-settings/slnt-negative.png index c07f1e09d..36cd4484f 100644 Binary files a/crates/resvg/tests/tests/text/font-variation-settings/slnt-negative.png and b/crates/resvg/tests/tests/text/font-variation-settings/slnt-negative.png differ diff --git a/crates/resvg/tests/tests/text/font-variation-settings/wdth-151.png b/crates/resvg/tests/tests/text/font-variation-settings/wdth-151.png index 9d2aca7cc..0065f0758 100644 Binary files a/crates/resvg/tests/tests/text/font-variation-settings/wdth-151.png and b/crates/resvg/tests/tests/text/font-variation-settings/wdth-151.png differ diff --git a/crates/resvg/tests/tests/text/font-variation-settings/wdth-25.png b/crates/resvg/tests/tests/text/font-variation-settings/wdth-25.png index 482dc68c5..91c571855 100644 Binary files a/crates/resvg/tests/tests/text/font-variation-settings/wdth-25.png and b/crates/resvg/tests/tests/text/font-variation-settings/wdth-25.png differ diff --git a/crates/resvg/tests/tests/text/font-variation-settings/wght-100.png b/crates/resvg/tests/tests/text/font-variation-settings/wght-100.png index 29de617c3..650ae9d86 100644 Binary files a/crates/resvg/tests/tests/text/font-variation-settings/wght-100.png and b/crates/resvg/tests/tests/text/font-variation-settings/wght-100.png differ diff --git a/crates/resvg/tests/tests/text/font-variation-settings/wght-700.png b/crates/resvg/tests/tests/text/font-variation-settings/wght-700.png index 43fe14e4e..90e0d815f 100644 Binary files a/crates/resvg/tests/tests/text/font-variation-settings/wght-700.png and b/crates/resvg/tests/tests/text/font-variation-settings/wght-700.png differ diff --git a/crates/resvg/tests/tests/text/font-variation-settings/xtra-extreme.png b/crates/resvg/tests/tests/text/font-variation-settings/xtra-extreme.png index 81a24d168..c4e2511c5 100644 Binary files a/crates/resvg/tests/tests/text/font-variation-settings/xtra-extreme.png and b/crates/resvg/tests/tests/text/font-variation-settings/xtra-extreme.png differ diff --git a/crates/resvg/tests/tests/text/text/zalgo.png b/crates/resvg/tests/tests/text/text/zalgo.png index 3e7ca99db..b0ae2b0fd 100644 Binary files a/crates/resvg/tests/tests/text/text/zalgo.png and b/crates/resvg/tests/tests/text/text/zalgo.png differ diff --git a/crates/usvg/Cargo.toml b/crates/usvg/Cargo.toml index 0cbff1599..7b177e999 100644 --- a/crates/usvg/Cargo.toml +++ b/crates/usvg/Cargo.toml @@ -36,11 +36,9 @@ simplecss = "0.2" siphasher = "1.0" # perfect hash implementation # text -fontdb = { version = "0.23.0", default-features = false, optional = true } -rustybuzz = { version = "0.20.1", optional = true } -# Note: ttf-parser is re-exported from rustybuzz, but we need gvar-alloc for variable fonts -# with many variation axes (like Roboto Flex which has 13 axes) -ttf-parser = { version = "0.25.1", features = ["gvar-alloc"], optional = true } +fontdb = { path = "../fontdb", default-features = false, optional = true } +harfrust = { version = "0.5", optional = true } +skrifa = { version = "0.40", optional = true } unicode-bidi = { version = "0.3", optional = true } unicode-script = { version = "0.5", optional = true } unicode-vo = { version = "0.1", optional = true } @@ -52,7 +50,7 @@ once_cell = "1.21" default = ["text", "system-fonts", "memmap-fonts"] # Enables text-to-path conversion support. # Adds around 400KiB to your binary. -text = ["fontdb", "rustybuzz", "ttf-parser", "unicode-bidi", "unicode-script", "unicode-vo"] +text = ["fontdb", "harfrust", "skrifa", "unicode-bidi", "unicode-script", "unicode-vo"] # Enables system fonts loading. system-fonts = ["fontdb/fs", "fontdb/fontconfig"] # Enables font files memmaping for faster loading. diff --git a/crates/usvg/src/parser/converter.rs b/crates/usvg/src/parser/converter.rs index 38bdbdbff..234a81260 100644 --- a/crates/usvg/src/parser/converter.rs +++ b/crates/usvg/src/parser/converter.rs @@ -11,17 +11,17 @@ use fontdb::Database; #[cfg(feature = "text")] use fontdb::ID; #[cfg(feature = "text")] -use rustybuzz::ttf_parser::GlyphId; +use skrifa::GlyphId; use svgtypes::{Length, LengthUnit as Unit, PaintOrderKind, TransformOrigin}; use tiny_skia_path::PathBuilder; use super::svgtree::{self, AId, EId, FromValue, SvgNode}; use super::units::{self, convert_length}; use super::{Error, Options, marker}; -#[cfg(feature = "text")] -use crate::flatten::BitmapImage; use crate::parser::paint_server::process_paint; #[cfg(feature = "text")] +use crate::text::flatten::BitmapImage; +#[cfg(feature = "text")] use crate::text::flatten::DatabaseExt; use crate::*; diff --git a/crates/usvg/src/text/flatten.rs b/crates/usvg/src/text/flatten.rs index e5fa39f76..90b668b7e 100644 --- a/crates/usvg/src/text/flatten.rs +++ b/crates/usvg/src/text/flatten.rs @@ -5,12 +5,16 @@ use std::mem; use std::sync::Arc; use fontdb::{Database, ID}; -use rustybuzz::ttf_parser; -use rustybuzz::ttf_parser::{GlyphId, RasterImageFormat, RgbaColor}; +use harfrust::Tag; +use skrifa::{ + FontRef, GlyphId, MetadataProvider, + bitmap::BitmapData, + instance::{LocationRef, Size as SkrifaSize}, + outline::{DrawSettings, OutlinePen, pen::ControlBoundsPen}, + setting::VariationSetting, +}; use tiny_skia_path::{NonZeroRect, Size, Transform}; -use xmlwriter::XmlWriter; -use crate::text::colr::GlyphPainter; use crate::*; fn resolve_rendering_mode(text: &Text) -> ShapeRendering { @@ -45,9 +49,9 @@ fn push_outline_paths( } } +/// Convert positioned glyphs to path outlines. pub(crate) fn flatten(text: &mut Text, cache: &mut Cache) -> Option<(Group, NonZeroRect)> { let mut new_children = vec![]; - let rendering_mode = resolve_rendering_mode(text); for span in &text.layouted { @@ -63,32 +67,29 @@ pub(crate) fn flatten(text: &mut Text, cache: &mut Cache) -> Option<(Group, NonZ new_children.push(Node::Path(Box::new(path))); } - // Instead of always processing each glyph separately, we always collect - // as many outline glyphs as possible by pushing them into the span_builder - // and only if we encounter a different glyph, or we reach the very end of the - // span to we push the actual outline paths into new_children. This way, we don't need - // to create a new path for every glyph if we have many consecutive glyphs - // with just outlines (which is the most common case). let mut span_builder = tiny_skia_path::PathBuilder::new(); - // For variable fonts, we need to extract the outline with variations applied. - // We can't use the cache here since the outline depends on variation values. + // Check if we need variations for this span. let has_explicit_variations = !span.variations.is_empty(); for glyph in &span.positioned_glyphs { + // Only use variations path if we have explicit variations OR + // if font-optical-sizing is auto AND the font has an opsz axis + let needs_variations = has_explicit_variations + || (span.font_optical_sizing == crate::FontOpticalSizing::Auto + && cache.has_opsz_axis(glyph.font)); + // A (best-effort conversion of a) COLR glyph. if let Some(tree) = cache.fontdb_colr(glyph.font, glyph.id) { let mut group = Group { transform: glyph.colr_transform(), ..Group::empty() }; - // TODO: Probably need to update abs_transform of children? group.children.push(Node::Group(Box::new(tree.root))); group.calculate_bounding_boxes(); - new_children.push(Node::Group(Box::new(group))); } - // An SVG glyph. Will return the usvg node containing the glyph descriptions. + // An SVG glyph. else if let Some(node) = cache.fontdb_svg(glyph.font, glyph.id) { push_outline_paths(span, &mut span_builder, &mut new_children, rendering_mode); @@ -96,10 +97,8 @@ pub(crate) fn flatten(text: &mut Text, cache: &mut Cache) -> Option<(Group, NonZ transform: glyph.svg_transform(), ..Group::empty() }; - // TODO: Probably need to update abs_transform of children? group.children.push(node); group.calculate_bounding_boxes(); - new_children.push(Node::Group(Box::new(group))); } // A bitmap glyph. @@ -108,20 +107,15 @@ pub(crate) fn flatten(text: &mut Text, cache: &mut Cache) -> Option<(Group, NonZ let transform = if img.is_sbix { glyph.sbix_transform( - img.x as f32, - img.y as f32, - img.glyph_bbox.map(|bbox| bbox.x_min).unwrap_or(0) as f32, - img.glyph_bbox.map(|bbox| bbox.y_min).unwrap_or(0) as f32, - img.pixels_per_em as f32, + img.x, + img.y, + img.glyph_bbox.map(|bbox| bbox.x_min as f32).unwrap_or(0.0), + img.glyph_bbox.map(|bbox| bbox.y_min as f32).unwrap_or(0.0), + img.pixels_per_em, img.image.size.height(), ) } else { - glyph.cbdt_transform( - img.x as f32, - img.y as f32, - img.pixels_per_em as f32, - img.image.size.height(), - ) + glyph.cbdt_transform(img.x, img.y, img.pixels_per_em) }; let mut group = Group { @@ -130,22 +124,13 @@ pub(crate) fn flatten(text: &mut Text, cache: &mut Cache) -> Option<(Group, NonZ }; group.children.push(Node::Image(Box::new(img.image))); group.calculate_bounding_boxes(); - new_children.push(Node::Group(Box::new(group))); } else { - // Only bypass cache if: explicit variations OR (auto opsz AND font has opsz axis) - let needs_variations = has_explicit_variations - || (span.font_optical_sizing == crate::FontOpticalSizing::Auto - && cache.has_opsz_axis(glyph.font)); - + // Regular outline glyph let outline = if needs_variations { - cache.fontdb.outline_with_variations( - glyph.font, - glyph.id, - &span.variations, - glyph.font_size(), - span.font_optical_sizing, - ) + cache + .fontdb + .outline_with_variations(glyph.font, glyph.id, &span.variations) } else { cache.fontdb_outline(glyph.font, glyph.id) }; @@ -180,11 +165,24 @@ pub(crate) fn flatten(text: &mut Text, cache: &mut Cache) -> Option<(Group, NonZ Some((group, stroke_bbox)) } -struct PathBuilder { +// SkrifaPen for outline drawing +struct SkrifaPen { builder: tiny_skia_path::PathBuilder, } -impl ttf_parser::OutlineBuilder for PathBuilder { +impl SkrifaPen { + fn new() -> Self { + Self { + builder: tiny_skia_path::PathBuilder::new(), + } + } + + fn finish(self) -> Option { + self.builder.finish() + } +} + +impl OutlinePen for SkrifaPen { fn move_to(&mut self, x: f32, y: f32) { self.builder.move_to(x, y); } @@ -193,12 +191,12 @@ impl ttf_parser::OutlineBuilder for PathBuilder { self.builder.line_to(x, y); } - fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) { - self.builder.quad_to(x1, y1, x, y); + fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) { + self.builder.quad_to(cx0, cy0, x, y); } - fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) { - self.builder.cubic_to(x1, y1, x2, y2, x, y); + fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) { + self.builder.cubic_to(cx0, cy0, cx1, cy1, x, y); } fn close(&mut self) { @@ -206,6 +204,7 @@ impl ttf_parser::OutlineBuilder for PathBuilder { } } +// DatabaseExt trait for skrifa-based font operations pub(crate) trait DatabaseExt { fn outline(&self, id: ID, glyph_id: GlyphId) -> Option; fn outline_with_variations( @@ -213,8 +212,6 @@ pub(crate) trait DatabaseExt { id: ID, glyph_id: GlyphId, variations: &[crate::FontVariation], - font_size: f32, - font_optical_sizing: crate::FontOpticalSizing, ) -> Option; fn has_opsz_axis(&self, id: ID) -> bool; fn raster(&self, id: ID, glyph_id: GlyphId) -> Option; @@ -222,35 +219,38 @@ pub(crate) trait DatabaseExt { fn colr(&self, id: ID, glyph_id: GlyphId) -> Option; } +/// Bounding box for a glyph +#[derive(Clone, Copy, Debug)] +#[allow(dead_code)] +pub(crate) struct GlyphBbox { + pub x_min: i16, + pub y_min: i16, + pub x_max: i16, + pub y_max: i16, +} + #[derive(Clone)] pub(crate) struct BitmapImage { - image: Image, - x: i16, - y: i16, - pixels_per_em: u16, - glyph_bbox: Option, - is_sbix: bool, + pub(crate) image: Image, + pub(crate) x: f32, + pub(crate) y: f32, + pub(crate) pixels_per_em: f32, + pub(crate) glyph_bbox: Option, + pub(crate) is_sbix: bool, } impl DatabaseExt for Database { #[inline(never)] fn outline(&self, id: ID, glyph_id: GlyphId) -> Option { self.with_face_data(id, |data, face_index| -> Option { - let mut font = ttf_parser::Face::parse(data, face_index).ok()?; - - // For variable fonts, we need to set default variation values to get proper outlines - if font.is_variable() { - for axis in font.variation_axes() { - font.set_variation(axis.tag, axis.def_value); - } - } - - let mut builder = PathBuilder { - builder: tiny_skia_path::PathBuilder::new(), - }; - - font.outline_glyph(glyph_id, &mut builder)?; - builder.builder.finish() + let font = FontRef::from_index(data, face_index).ok()?; + let outlines = font.outline_glyphs(); + let glyph = outlines.get(glyph_id)?; + + let mut pen = SkrifaPen::new(); + let settings = DrawSettings::unhinted(SkrifaSize::unscaled(), LocationRef::default()); + glyph.draw(settings, &mut pen).ok()?; + pen.finish() })? } @@ -260,50 +260,38 @@ impl DatabaseExt for Database { id: ID, glyph_id: GlyphId, variations: &[crate::FontVariation], - font_size: f32, - font_optical_sizing: crate::FontOpticalSizing, ) -> Option { self.with_face_data(id, |data, face_index| -> Option { - let mut font = ttf_parser::Face::parse(data, face_index).ok()?; - - for v in variations { - font.set_variation(ttf_parser::Tag::from_bytes(&v.tag), v.value); - } - - // Auto-set opsz if font-optical-sizing is auto and not explicitly set - if font_optical_sizing == crate::FontOpticalSizing::Auto { - let has_explicit_opsz = variations.iter().any(|v| v.tag == *b"opsz"); - if !has_explicit_opsz { - // Check if font has opsz axis - if let Some(axes) = font.tables().fvar { - let has_opsz_axis = axes - .axes - .into_iter() - .any(|axis| axis.tag == ttf_parser::Tag::from_bytes(b"opsz")); - if has_opsz_axis { - font.set_variation(ttf_parser::Tag::from_bytes(b"opsz"), font_size); - } - } - } - } - - let mut builder = PathBuilder { - builder: tiny_skia_path::PathBuilder::new(), - }; - - font.outline_glyph(glyph_id, &mut builder)?; - builder.builder.finish() + let font = FontRef::from_index(data, face_index).ok()?; + let outlines = font.outline_glyphs(); + let glyph = outlines.get(glyph_id)?; + + // Build variation coordinates using avar-aware normalization + let axes = font.axes(); + let mut coords: Vec = + vec![Default::default(); axes.len()]; + + // Build variation settings (auto-opsz is already included in variations) + let settings: Vec = variations + .iter() + .map(|v| VariationSetting::new(Tag::new(&v.tag), v.value)) + .collect(); + + // Use location_to_slice which applies avar table remapping + axes.location_to_slice(&settings, &mut coords); + + let location = LocationRef::new(&coords); + let mut pen = SkrifaPen::new(); + let draw_settings = DrawSettings::unhinted(SkrifaSize::unscaled(), location); + glyph.draw(draw_settings, &mut pen).ok()?; + pen.finish() })? } fn has_opsz_axis(&self, id: ID) -> bool { self.with_face_data(id, |data, face_index| -> Option { - let font = ttf_parser::Face::parse(data, face_index).ok()?; - let has_opsz = font.tables().fvar.map_or(false, |axes| { - axes.axes - .into_iter() - .any(|axis| axis.tag == ttf_parser::Tag::from_bytes(b"opsz")) - }); + let font = FontRef::from_index(data, face_index).ok()?; + let has_opsz = font.axes().iter().any(|a| a.tag() == Tag::new(b"opsz")); Some(has_opsz) }) .flatten() @@ -312,106 +300,209 @@ impl DatabaseExt for Database { fn raster(&self, id: ID, glyph_id: GlyphId) -> Option { self.with_face_data(id, |data, face_index| -> Option { - let font = ttf_parser::Face::parse(data, face_index).ok()?; - let image = font.glyph_raster_image(glyph_id, u16::MAX)?; - - if image.format == RasterImageFormat::PNG { - let bitmap_image = BitmapImage { - image: Image { - id: String::new(), - visible: true, - size: Size::from_wh(image.width as f32, image.height as f32)?, - rendering_mode: ImageRendering::OptimizeQuality, - kind: ImageKind::PNG(Arc::new(image.data.into())), - abs_transform: Transform::default(), - abs_bounding_box: NonZeroRect::from_xywh( - 0.0, - 0.0, - image.width as f32, - image.height as f32, - )?, - }, - x: image.x, - y: image.y, - pixels_per_em: image.pixels_per_em, - glyph_bbox: font.glyph_bounding_box(glyph_id), - // ttf-parser always checks sbix first, so if this table exists, it was used. - is_sbix: font.tables().sbix.is_some(), - }; + let font = FontRef::from_index(data, face_index).ok()?; + + // Get largest strike (like ttf-parser's u16::MAX behavior) + let strikes = font.bitmap_strikes(); + let strike = strikes.iter().max_by(|a, b| { + a.ppem() + .partial_cmp(&b.ppem()) + .unwrap_or(std::cmp::Ordering::Equal) + })?; + + let bitmap_glyph = strike.get(glyph_id)?; + + // Only handle PNG format (matching original ttf-parser behavior) + let png_data = match bitmap_glyph.data { + BitmapData::Png(data) => data, + _ => return None, + }; - return Some(bitmap_image); - } + // Get dimensions from PNG header + let (width, height) = if let Ok(size) = imagesize::blob_size(png_data) { + (size.width as u32, size.height as u32) + } else { + let ppem = strike.ppem(); + (ppem as u32, ppem as u32) + }; - None + let glyph_bbox = { + let outlines = font.outline_glyphs(); + outlines.get(glyph_id).and_then(|glyph| { + let mut bounds_pen = ControlBoundsPen::new(); + let settings = + DrawSettings::unhinted(SkrifaSize::unscaled(), LocationRef::default()); + glyph.draw(settings, &mut bounds_pen).ok()?; + bounds_pen.bounding_box().map(|bb| GlyphBbox { + x_min: bb.x_min.clamp(i16::MIN as f32, i16::MAX as f32) as i16, + y_min: bb.y_min.clamp(i16::MIN as f32, i16::MAX as f32) as i16, + x_max: bb.x_max.clamp(i16::MIN as f32, i16::MAX as f32) as i16, + y_max: bb.y_max.clamp(i16::MIN as f32, i16::MAX as f32) as i16, + }) + }) + }; + + let is_sbix = font.table_data(Tag::new(b"sbix")).is_some(); + + let bitmap_image = BitmapImage { + image: Image { + id: String::new(), + visible: true, + size: Size::from_wh(width as f32, height as f32)?, + rendering_mode: ImageRendering::OptimizeQuality, + kind: ImageKind::PNG(Arc::new(png_data.to_vec())), + abs_transform: Transform::default(), + abs_bounding_box: NonZeroRect::from_xywh( + 0.0, + 0.0, + width as f32, + height as f32, + )?, + }, + x: bitmap_glyph.inner_bearing_x, + y: bitmap_glyph.inner_bearing_y, + pixels_per_em: strike.ppem(), + glyph_bbox, + is_sbix, + }; + + Some(bitmap_image) })? } fn svg(&self, id: ID, glyph_id: GlyphId) -> Option { - // TODO: Technically not 100% accurate because the SVG format in a OTF font - // is actually a subset/superset of a normal SVG, but it seems to work fine - // for Twitter Color Emoji, so might as well use what we already have. - - // TODO: Glyph records can contain the data for multiple glyphs. We should - // add a cache so we don't need to reparse the data every time. self.with_face_data(id, |data, face_index| -> Option { - let font = ttf_parser::Face::parse(data, face_index).ok()?; - let image = font.glyph_svg_image(glyph_id)?; - let tree = Tree::from_data(image.data, &Options::default()).ok()?; - - // Twitter Color Emoji seems to always have one SVG record per glyph, - // while Noto Color Emoji sometimes contains multiple ones. It's kind of hacky, - // but the best we have for now. - let node = if image.start_glyph_id == image.end_glyph_id { - Node::Group(Box::new(tree.root)) - } else { - tree.node_by_id(&format!("glyph{}", glyph_id.0)) - .log_none(|| { - log::warn!("Failed to find SVG glyph node for glyph {}", glyph_id.0); - }) - .cloned()? - }; + let font = FontRef::from_index(data, face_index).ok()?; + + let svg_table = font.table_data(Tag::new(b"SVG "))?; + let svg_data = svg_table.as_ref(); + + if svg_data.len() < 10 { + return None; + } + + let _version = u16::from_be_bytes([svg_data[0], svg_data[1]]); + let doc_list_offset = + u32::from_be_bytes([svg_data[2], svg_data[3], svg_data[4], svg_data[5]]) as usize; - Some(node) + if doc_list_offset + 2 > svg_data.len() { + return None; + } + + let doc_list = &svg_data[doc_list_offset..]; + let num_entries = u16::from_be_bytes([doc_list[0], doc_list[1]]) as usize; + + let entries_start = 2; + let glyph_id_val = glyph_id.to_u32() as u16; + + for i in 0..num_entries { + let entry_offset = entries_start + i * 12; + if entry_offset + 12 > doc_list.len() { + break; + } + + let entry = &doc_list[entry_offset..entry_offset + 12]; + let start_glyph = u16::from_be_bytes([entry[0], entry[1]]); + let end_glyph = u16::from_be_bytes([entry[2], entry[3]]); + let svg_doc_offset = + u32::from_be_bytes([entry[4], entry[5], entry[6], entry[7]]) as usize; + let svg_doc_length = + u32::from_be_bytes([entry[8], entry[9], entry[10], entry[11]]) as usize; + + if glyph_id_val >= start_glyph && glyph_id_val <= end_glyph { + let abs_offset = doc_list_offset + svg_doc_offset; + if abs_offset + svg_doc_length > svg_data.len() { + return None; + } + + let svg_doc_data = &svg_data[abs_offset..abs_offset + svg_doc_length]; + + let svg_bytes: std::borrow::Cow<[u8]> = + if svg_doc_data.starts_with(&[0x1f, 0x8b]) { + use std::io::Read; + let mut decoder = flate2::read::GzDecoder::new(svg_doc_data); + let mut decompressed = Vec::new(); + if decoder.read_to_end(&mut decompressed).is_err() { + return None; + } + std::borrow::Cow::Owned(decompressed) + } else { + std::borrow::Cow::Borrowed(svg_doc_data) + }; + + let tree = + crate::Tree::from_data(&svg_bytes, &crate::Options::default()).ok()?; + + let node = if start_glyph == end_glyph { + Node::Group(Box::new(tree.root)) + } else { + let glyph_node_id = format!("glyph{}", glyph_id_val); + tree.node_by_id(&glyph_node_id).cloned()? + }; + + return Some(node); + } + } + + None })? } fn colr(&self, id: ID, glyph_id: GlyphId) -> Option { - self.with_face_data(id, |data, face_index| -> Option { - let face = ttf_parser::Face::parse(data, face_index).ok()?; - - let mut svg = XmlWriter::new(xmlwriter::Options::default()); - - svg.start_element("svg"); - svg.write_attribute("xmlns", "http://www.w3.org/2000/svg"); - svg.write_attribute("xmlns:xlink", "http://www.w3.org/1999/xlink"); - - let mut path_buf = String::with_capacity(256); - let gradient_index = 1; - let clip_path_index = 1; - - svg.start_element("g"); - - let mut glyph_painter = GlyphPainter { - face: &face, - svg: &mut svg, - path_buf: &mut path_buf, - gradient_index, - clip_path_index, - palette_index: 0, - transform: ttf_parser::Transform::default(), - outline_transform: ttf_parser::Transform::default(), - transforms_stack: vec![ttf_parser::Transform::default()], - }; - - face.paint_color_glyph( - glyph_id, - 0, - RgbaColor::new(0, 0, 0, 255), - &mut glyph_painter, - )?; - svg.end_element(); + let result = self.with_face_data(id, |data, face_index| { + super::skrifa_colr::paint_colr_glyph(data, face_index, glyph_id) + })?; + result + } +} - Tree::from_data(svg.end_document().as_bytes(), &Options::default()).ok() - })? +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_skrifa_variable_font() { + let font_path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../crates/resvg/tests/fonts/RobotoFlex.subset.ttf" + ); + let font_data = std::fs::read(font_path).expect("Font not found"); + + let font = FontRef::new(&font_data).expect("Failed to parse font"); + let outlines = font.outline_glyphs(); + + let charmap = font.charmap(); + let glyph_id = charmap.map('N').expect("Glyph not found"); + let glyph = outlines.get(glyph_id).expect("Outline not found"); + + let axes = font.axes(); + + let wdth_idx = axes + .iter() + .position(|a| a.tag() == Tag::new(b"wdth")) + .expect("wdth axis not found"); + + let mut pen1 = SkrifaPen::new(); + let settings1 = DrawSettings::unhinted(SkrifaSize::unscaled(), LocationRef::default()); + glyph.draw(settings1, &mut pen1).expect("Draw failed"); + let path1 = pen1.finish().expect("Path failed"); + let bounds1 = path1.bounds(); + + let mut coords = vec![skrifa::instance::NormalizedCoord::default(); axes.len()]; + coords[wdth_idx] = axes.get(wdth_idx).unwrap().normalize(25.0); + + let location = LocationRef::new(&coords); + let mut pen2 = SkrifaPen::new(); + let settings2 = DrawSettings::unhinted(SkrifaSize::unscaled(), location); + glyph.draw(settings2, &mut pen2).expect("Draw failed"); + let path2 = pen2.finish().expect("Path failed"); + let bounds2 = path2.bounds(); + + assert!( + bounds2.width() < bounds1.width(), + "wdth=25 should be narrower than default! default width: {}, wdth=25 width: {}", + bounds1.width(), + bounds2.width() + ); } } diff --git a/crates/usvg/src/text/layout.rs b/crates/usvg/src/text/layout.rs index 448d6545d..338725a8d 100644 --- a/crates/usvg/src/text/layout.rs +++ b/crates/usvg/src/text/layout.rs @@ -6,9 +6,9 @@ use std::num::NonZeroU16; use std::sync::Arc; use fontdb::{Database, ID}; +use harfrust::Tag; use kurbo::{ParamCurve, ParamCurveArclen, ParamCurveDeriv}; -use rustybuzz::ttf_parser; -use rustybuzz::ttf_parser::{GlyphId, Tag}; +use skrifa::{GlyphId, MetadataProvider}; use strict_num::NonZeroPositiveF32; use tiny_skia_path::{NonZeroRect, Transform}; use unicode_script::UnicodeScript; @@ -71,18 +71,32 @@ impl PositionedGlyph { .pre_concat(Transform::from_scale(1.0, -1.0)) } - /// Returns the transform for the glyph, assuming that a CBTD-based raster glyph + /// Returns the transform for the glyph, assuming that a CBDT-based raster glyph /// is being used. - pub fn cbdt_transform(&self, x: f32, y: f32, pixels_per_em: f32, height: f32) -> Transform { + pub fn cbdt_transform(&self, x: f32, y: f32, pixels_per_em: f32) -> Transform { + // Bitmap glyphs are in pixel units, not font units. + // self.transform() includes scale = font_size / units_per_em (for outline glyphs). + // We need to counteract that and apply the correct bitmap scaling. + // Total scale should be: font_size / pixels_per_em + // So we need: (font_size / units_per_em) * scale = font_size / pixels_per_em + // Therefore: scale = units_per_em / pixels_per_em + // + // When font_size == pixels_per_em, total scale = 1.0 (pixel-perfect rendering). + // Use a tolerance to snap to exactly 1.0 and avoid floating point precision issues. + let bitmap_scale = self.units_per_em as f32 / pixels_per_em; + let total_scale = (self.font_size / self.units_per_em as f32) * bitmap_scale; + let scale = if (total_scale - 1.0).abs() < 0.00001 { + // Snap to exactly 1.0 for pixel-perfect rendering + self.units_per_em as f32 / self.font_size + } else { + bitmap_scale + }; + self.transform() - .pre_concat(Transform::from_scale( - self.units_per_em as f32 / pixels_per_em, - self.units_per_em as f32 / pixels_per_em, - )) - // Right now, the top-left corner of the image would be placed in - // on the "text cursor", but we want the bottom-left corner to be there, - // so we need to shift it up and also apply the x/y offset. - .pre_translate(x, -height - y) + .pre_concat(Transform::from_scale(scale, scale)) + // The y value from skrifa's inner_bearing_y points to the top of the glyph. + // We negate it to convert from font coordinates (y-up) to image coordinates (y-down). + .pre_translate(x, -y) } /// Returns the transform for the glyph, assuming that a sbix-based raster glyph @@ -152,9 +166,9 @@ pub struct Span { pub paint_order: PaintOrder, /// The font size of the span. pub font_size: NonZeroPositiveF32, - /// Font variation settings for variable fonts. + /// Font variation settings for variable fonts (uniform for all glyphs in span). pub variations: Vec, - /// Font optical sizing mode. + /// Font optical sizing mode for auto-opsz computation. pub font_optical_sizing: crate::FontOpticalSizing, /// The visibility of the span. pub visible: bool, @@ -347,12 +361,21 @@ pub(crate) fn layout_text( }) .collect(); + // Compute effective variations for this span (including auto-opsz). + let effective_variations = compute_effective_variations( + &span.font.variations, + span.font_size.get(), + span.font_optical_sizing, + font.id, + fontdb, + ); + spans.push(Span { fill, stroke: span.stroke.clone(), paint_order: span.paint_order, font_size: span.font_size, - variations: span.font.variations.clone(), + variations: effective_variations, font_optical_sizing: span.font_optical_sizing, visible: span.visible, positioned_glyphs, @@ -1203,89 +1226,21 @@ pub(crate) trait DatabaseExt { fn has_char(&self, id: ID, c: char) -> bool; } +// Skrifa-based implementation for font metrics impl DatabaseExt for Database { #[inline(never)] fn load_font(&self, id: ID) -> Option { - self.with_face_data(id, |data, face_index| -> Option { - let font = ttf_parser::Face::parse(data, face_index).ok()?; - - let units_per_em = NonZeroU16::new(font.units_per_em())?; - - let ascent = font.ascender(); - let descent = font.descender(); - - let x_height = font - .x_height() - .and_then(|x| u16::try_from(x).ok()) - .and_then(NonZeroU16::new); - let x_height = match x_height { - Some(height) => height, - None => { - // If not set - fallback to height * 45%. - // 45% is what Firefox uses. - u16::try_from((f32::from(ascent - descent) * 0.45) as i32) - .ok() - .and_then(NonZeroU16::new)? - } - }; - - let line_through = font.strikeout_metrics(); - let line_through_position = match line_through { - Some(metrics) => metrics.position, - None => x_height.get() as i16 / 2, - }; - - let (underline_position, underline_thickness) = match font.underline_metrics() { - Some(metrics) => { - let thickness = u16::try_from(metrics.thickness) - .ok() - .and_then(NonZeroU16::new) - // `ttf_parser` guarantees that units_per_em is >= 16 - .unwrap_or_else(|| NonZeroU16::new(units_per_em.get() / 12).unwrap()); - - (metrics.position, thickness) - } - None => ( - -(units_per_em.get() as i16) / 9, - NonZeroU16::new(units_per_em.get() / 12).unwrap(), - ), - }; - - // 0.2 and 0.4 are generic offsets used by some applications (Inkscape/librsvg). - let mut subscript_offset = (units_per_em.get() as f32 / 0.2).round() as i16; - let mut superscript_offset = (units_per_em.get() as f32 / 0.4).round() as i16; - if let Some(metrics) = font.subscript_metrics() { - subscript_offset = metrics.y_offset; - } - - if let Some(metrics) = font.superscript_metrics() { - superscript_offset = metrics.y_offset; - } - - Some(ResolvedFont { - id, - units_per_em, - ascent, - descent, - x_height, - underline_position, - underline_thickness, - line_through_position, - subscript_offset, - superscript_offset, - }) + self.with_face_data(id, |data, face_index| { + super::skrifa_metrics::load_font_metrics(data, face_index, id) })? } #[inline(never)] fn has_char(&self, id: ID, c: char) -> bool { - let res = self.with_face_data(id, |font_data, face_index| -> Option { - let font = ttf_parser::Face::parse(font_data, face_index).ok()?; - font.glyph_index(c)?; - Some(true) - }); - - res == Some(Some(true)) + self.with_face_data(id, |font_data, face_index| { + super::skrifa_metrics::has_char(font_data, face_index, c) + }) + .unwrap_or(false) } } @@ -1406,13 +1361,13 @@ fn shape_text_with_font( fontdb: &fontdb::Database, ) -> Option> { fontdb.with_face_data(font.id, |font_data, face_index| -> Option> { - let mut rb_font = rustybuzz::Face::from_slice(font_data, face_index)?; + let hr_font = harfrust::FontRef::from_index(font_data, face_index).ok()?; // Build the list of variations to apply - let mut final_variations: Vec = variations + let mut final_variations: Vec = variations .iter() - .map(|v| rustybuzz::Variation { - tag: Tag::from_bytes(&v.tag), + .map(|v| harfrust::Variation { + tag: Tag::new(&v.tag), value: v.value, }) .collect(); @@ -1423,15 +1378,13 @@ fn shape_text_with_font( if font_optical_sizing == crate::FontOpticalSizing::Auto { let has_explicit_opsz = variations.iter().any(|v| v.tag == *b"opsz"); if !has_explicit_opsz { - // Check if font has opsz axis using the already parsed rb_font - if let Some(axes) = rb_font.tables().fvar { - let has_opsz_axis = axes - .axes - .into_iter() - .any(|axis| axis.tag == ttf_parser::Tag::from_bytes(b"opsz")); + // Check if font has opsz axis using skrifa + if let Ok(skrifa_font) = skrifa::FontRef::from_index(font_data, face_index) { + let axes = skrifa_font.axes(); + let has_opsz_axis = axes.iter().any(|axis| axis.tag() == Tag::new(b"opsz")); if has_opsz_axis { - final_variations.push(rustybuzz::Variation { - tag: Tag::from_bytes(b"opsz"), + final_variations.push(harfrust::Variation { + tag: Tag::new(b"opsz"), value: font_size, }); } @@ -1439,10 +1392,22 @@ fn shape_text_with_font( } } - // Apply font variations for variable fonts - if !final_variations.is_empty() { - rb_font.set_variations(&final_variations); - } + // Create shaper data and instance + let shaper_data = harfrust::ShaperData::new(&hr_font); + let shaper_instance = if !final_variations.is_empty() { + Some(harfrust::ShaperInstance::from_variations( + &hr_font, + final_variations, + )) + } else { + None + }; + + // Build shaper with optional instance + let shaper = shaper_data + .shaper(&hr_font) + .instance(shaper_instance.as_ref()) + .build(); let bidi_info = unicode_bidi::BidiInfo::new(text, Some(unicode_bidi::Level::ltr())); let paragraph = &bidi_info.paragraphs[0]; @@ -1458,31 +1423,37 @@ fn shape_text_with_font( } let ltr = levels[run.start].is_ltr(); - let hb_direction = if ltr { - rustybuzz::Direction::LeftToRight + let hr_direction = if ltr { + harfrust::Direction::LeftToRight } else { - rustybuzz::Direction::RightToLeft + harfrust::Direction::RightToLeft }; - let mut buffer = rustybuzz::UnicodeBuffer::new(); + let mut buffer = harfrust::UnicodeBuffer::new(); buffer.push_str(sub_text); - buffer.set_direction(hb_direction); + buffer.set_direction(hr_direction); + // Set script based on the first character's script for proper shaping + // This is critical for Arabic and other complex scripts + if let Some(first_char) = sub_text.chars().next() { + let script = unicode_script_to_harfrust(first_char.script()); + buffer.set_script(script); + } let mut features = Vec::new(); if small_caps { - features.push(rustybuzz::Feature::new(Tag::from_bytes(b"smcp"), 1, ..)); + features.push(harfrust::Feature::new(Tag::new(b"smcp"), 1, ..)); } if !apply_kerning { - features.push(rustybuzz::Feature::new(Tag::from_bytes(b"kern"), 0, ..)); + features.push(harfrust::Feature::new(Tag::new(b"kern"), 0, ..)); } - let output = rustybuzz::shape(&rb_font, &features, buffer); + let output = shaper.shape(buffer, &features); let positions = output.glyph_positions(); let infos = output.glyph_infos(); - for i in 0..output.len() { + for i in 0usize..output.len() { let pos = positions[i]; let info = infos[i]; let idx = run.start + info.cluster as usize; @@ -1499,9 +1470,12 @@ fn shape_text_with_font( glyphs.push(Glyph { byte_idx: ByteIndex::new(idx), - cluster_len: end.checked_sub(start).unwrap_or(0), // TODO: can fail? + cluster_len: end.checked_sub(start).unwrap_or_else(|| { + log::warn!("Invalid cluster bounds: end={} < start={}", end, start); + 0 + }), text: sub_text[start..end].to_string(), - id: GlyphId(info.glyph_id as u16), + id: GlyphId::new(info.glyph_id as u32), dx: pos.x_offset, dy: pos.y_offset, width: pos.x_advance, @@ -1616,7 +1590,7 @@ pub(crate) struct Glyph { impl Glyph { fn is_missing(&self) -> bool { - self.id.0 == 0 + self.id.to_u32() == 0 } } @@ -1665,6 +1639,33 @@ pub(crate) fn is_word_separator_characters(c: char) -> bool { } impl ResolvedFont { + /// Creates a new ResolvedFont with all required metrics. + pub(crate) fn new( + id: ID, + units_per_em: NonZeroU16, + ascent: i16, + descent: i16, + x_height: NonZeroU16, + underline_position: i16, + underline_thickness: NonZeroU16, + line_through_position: i16, + subscript_offset: i16, + superscript_offset: i16, + ) -> Self { + Self { + id, + units_per_em, + ascent, + descent, + x_height, + underline_position, + underline_thickness, + line_through_position, + subscript_offset, + superscript_offset, + } + } + #[inline] pub(crate) fn scale(&self, font_size: f32) -> f32 { font_size / self.units_per_em.get() as f32 @@ -1812,3 +1813,70 @@ impl ByteIndex { text[self.0..].chars().next().unwrap() } } + +/// Converts unicode_script::Script to harfrust::Script +fn unicode_script_to_harfrust(script: unicode_script::Script) -> harfrust::Script { + use unicode_script::Script::*; + match script { + Arabic => harfrust::script::ARABIC, + Armenian => harfrust::script::ARMENIAN, + Bengali => harfrust::script::BENGALI, + Bopomofo => harfrust::script::BOPOMOFO, + Cyrillic => harfrust::script::CYRILLIC, + Devanagari => harfrust::script::DEVANAGARI, + Georgian => harfrust::script::GEORGIAN, + Greek => harfrust::script::GREEK, + Gujarati => harfrust::script::GUJARATI, + Gurmukhi => harfrust::script::GURMUKHI, + Han => harfrust::script::HAN, + Hangul => harfrust::script::HANGUL, + Hebrew => harfrust::script::HEBREW, + Hiragana => harfrust::script::HIRAGANA, + Kannada => harfrust::script::KANNADA, + Katakana => harfrust::script::KATAKANA, + Khmer => harfrust::script::KHMER, + Lao => harfrust::script::LAO, + Latin => harfrust::script::LATIN, + Malayalam => harfrust::script::MALAYALAM, + Myanmar => harfrust::script::MYANMAR, + Oriya => harfrust::script::ORIYA, + Sinhala => harfrust::script::SINHALA, + Syriac => harfrust::script::SYRIAC, + Tamil => harfrust::script::TAMIL, + Telugu => harfrust::script::TELUGU, + Thai => harfrust::script::THAI, + Tibetan => harfrust::script::TIBETAN, + _ => harfrust::script::COMMON, + } +} + +/// Computes effective font variations including automatic optical sizing. +/// +/// If `font_optical_sizing` is `Auto` and the font has an `opsz` axis that isn't +/// explicitly set in `variations`, this function adds `opsz=font_size` to match +/// browser behavior (CSS font-optical-sizing: auto). +fn compute_effective_variations( + variations: &[crate::FontVariation], + font_size: f32, + font_optical_sizing: crate::FontOpticalSizing, + font_id: ID, + fontdb: &fontdb::Database, +) -> Vec { + use super::flatten::DatabaseExt; + + let mut effective = variations.to_vec(); + + // Automatic optical sizing: if font-optical-sizing is auto and the font has + // an 'opsz' axis that isn't explicitly set, auto-set it to match font size. + if font_optical_sizing == crate::FontOpticalSizing::Auto { + let has_explicit_opsz = variations.iter().any(|v| v.tag == *b"opsz"); + if !has_explicit_opsz && fontdb.has_opsz_axis(font_id) { + effective.push(crate::FontVariation { + tag: *b"opsz", + value: font_size, + }); + } + } + + effective +} diff --git a/crates/usvg/src/text/mod.rs b/crates/usvg/src/text/mod.rs index 4b48274e1..e33ec160b 100644 --- a/crates/usvg/src/text/mod.rs +++ b/crates/usvg/src/text/mod.rs @@ -11,7 +11,9 @@ use crate::{Cache, Font, FontStretch, FontStyle, Text}; pub(crate) mod flatten; -mod colr; +mod skrifa_colr; +mod skrifa_metrics; + /// Provides access to the layout of a text node. pub mod layout; @@ -171,13 +173,13 @@ impl FontResolver<'_> { let base_family = base_face .families .iter() - .find(|f| f.1 == fontdb::Language::English_UnitedStates) + .find(|f| f.1 == fontdb::Language::EnglishUnitedStates) .unwrap_or(&base_face.families[0]); let new_family = face .families .iter() - .find(|f| f.1 == fontdb::Language::English_UnitedStates) + .find(|f| f.1 == fontdb::Language::EnglishUnitedStates) .unwrap_or(&base_face.families[0]); log::warn!("Fallback from {} to {}.", base_family.0, new_family.0); diff --git a/crates/usvg/src/text/skrifa_colr.rs b/crates/usvg/src/text/skrifa_colr.rs new file mode 100644 index 000000000..1d3687ca5 --- /dev/null +++ b/crates/usvg/src/text/skrifa_colr.rs @@ -0,0 +1,421 @@ +// Copyright 2026 the Resvg Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! COLRv1 color glyph painting using skrifa's ColorPainter. +//! +//! This module provides an alternative to ttf-parser for rendering COLR glyphs, +//! using skrifa's ColorPainter trait. This enables full COLRv1 support including +//! sweep/conic gradients. + +use skrifa::{ + FontRef, GlyphId, MetadataProvider, + color::{Brush, ColorGlyphFormat, ColorPainter, CompositeMode}, + instance::LocationRef, + outline::OutlinePen, + raw::types::BoundingBox, +}; +use xmlwriter::XmlWriter; + +use crate::{Options, Tree}; + +/// Skrifa-based pen for building SVG path data. +struct SvgPathPen<'a> { + path: &'a mut String, +} + +impl<'a> SvgPathPen<'a> { + fn new(path: &'a mut String) -> Self { + Self { path } + } + + fn finish(&mut self) { + if !self.path.is_empty() { + self.path.pop(); // remove trailing space + } + } +} + +impl OutlinePen for SvgPathPen<'_> { + fn move_to(&mut self, x: f32, y: f32) { + use std::fmt::Write; + write!(self.path, "M {} {} ", x, y).unwrap(); + } + + fn line_to(&mut self, x: f32, y: f32) { + use std::fmt::Write; + write!(self.path, "L {} {} ", x, y).unwrap(); + } + + fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) { + use std::fmt::Write; + write!(self.path, "Q {} {} {} {} ", cx0, cy0, x, y).unwrap(); + } + + fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) { + use std::fmt::Write; + write!(self.path, "C {} {} {} {} {} {} ", cx0, cy0, cx1, cy1, x, y).unwrap(); + } + + fn close(&mut self) { + self.path.push_str("Z "); + } +} + +/// COLR glyph painter that outputs SVG using skrifa's ColorPainter. +pub(crate) struct SkrifaGlyphPainter<'a> { + font: FontRef<'a>, + svg: &'a mut XmlWriter, + path_buf: &'a mut String, + gradient_index: usize, + clip_path_index: usize, + transform_stack: Vec, + current_transform: skrifa::color::Transform, +} + +impl<'a> SkrifaGlyphPainter<'a> { + pub fn new(font: FontRef<'a>, svg: &'a mut XmlWriter, path_buf: &'a mut String) -> Self { + Self { + font, + svg, + path_buf, + gradient_index: 1, + clip_path_index: 1, + transform_stack: Vec::new(), + current_transform: skrifa::color::Transform::default(), + } + } + + fn get_color(&self, palette_index: u16) -> Option { + // TODO: SVG 2 allows specifying color palette via CSS font-palette property. + // Currently we always use palette 0 (the default). Supporting font-palette + // would require passing the palette index through the rendering pipeline. + self.font + .color_palettes() + .get(0)? + .colors() + .get(palette_index as usize) + .copied() + } + + fn write_color(&mut self, name: &str, palette_index: u16, alpha: f32) { + if let Some(color) = self.get_color(palette_index) { + self.svg.write_attribute_fmt( + name, + format_args!("rgb({}, {}, {})", color.red, color.green, color.blue), + ); + let opacity = (color.alpha as f32 / 255.0) * alpha; + if opacity < 1.0 { + let opacity_name = if name == "fill" { + "fill-opacity" + } else { + "stop-opacity" + }; + self.svg.write_attribute(opacity_name, &opacity); + } + } + } + + fn write_transform(&mut self, name: &str, ts: skrifa::color::Transform) { + // Check if it's an identity transform (no transformation) + if ts.xx == 1.0 + && ts.yx == 0.0 + && ts.xy == 0.0 + && ts.yy == 1.0 + && ts.dx == 0.0 + && ts.dy == 0.0 + { + return; + } + + self.svg.write_attribute_fmt( + name, + format_args!( + "matrix({} {} {} {} {} {})", + ts.xx, ts.yx, ts.xy, ts.yy, ts.dx, ts.dy + ), + ); + } + + fn paint_solid(&mut self, palette_index: u16, alpha: f32) { + self.svg.start_element("path"); + self.write_color("fill", palette_index, alpha); + self.write_transform("transform", self.current_transform); + self.svg.write_attribute("d", self.path_buf); + self.svg.end_element(); + } + + fn paint_linear_gradient( + &mut self, + p0: skrifa::raw::types::Point, + p1: skrifa::raw::types::Point, + stops: &[skrifa::color::ColorStop], + extend: skrifa::color::Extend, + ) { + let gradient_id = format!("lg{}", self.gradient_index); + self.gradient_index += 1; + + self.svg.start_element("linearGradient"); + self.svg.write_attribute("id", &gradient_id); + self.svg.write_attribute("x1", &p0.x); + self.svg.write_attribute("y1", &p0.y); + self.svg.write_attribute("x2", &p1.x); + self.svg.write_attribute("y2", &p1.y); + self.svg.write_attribute("gradientUnits", &"userSpaceOnUse"); + self.write_spread_method(extend); + self.write_transform("gradientTransform", self.current_transform); + self.write_gradient_stops(stops); + self.svg.end_element(); + + self.svg.start_element("path"); + self.svg + .write_attribute_fmt("fill", format_args!("url(#{})", gradient_id)); + self.svg.write_attribute("d", self.path_buf); + self.svg.end_element(); + } + + fn paint_radial_gradient( + &mut self, + c0: skrifa::raw::types::Point, + r0: f32, + c1: skrifa::raw::types::Point, + r1: f32, + stops: &[skrifa::color::ColorStop], + extend: skrifa::color::Extend, + ) { + let gradient_id = format!("rg{}", self.gradient_index); + self.gradient_index += 1; + + self.svg.start_element("radialGradient"); + self.svg.write_attribute("id", &gradient_id); + self.svg.write_attribute("cx", &c1.x); + self.svg.write_attribute("cy", &c1.y); + self.svg.write_attribute("r", &r1); + self.svg.write_attribute("fr", &r0); + self.svg.write_attribute("fx", &c0.x); + self.svg.write_attribute("fy", &c0.y); + self.svg.write_attribute("gradientUnits", &"userSpaceOnUse"); + self.write_spread_method(extend); + self.write_transform("gradientTransform", self.current_transform); + self.write_gradient_stops(stops); + self.svg.end_element(); + + self.svg.start_element("path"); + self.svg + .write_attribute_fmt("fill", format_args!("url(#{})", gradient_id)); + self.svg.write_attribute("d", self.path_buf); + self.svg.end_element(); + } + + fn paint_sweep_gradient( + &mut self, + c0: skrifa::raw::types::Point, + start_angle: f32, + end_angle: f32, + stops: &[skrifa::color::ColorStop], + extend: skrifa::color::Extend, + ) { + // SVG doesn't have native sweep gradient support. + // We approximate with a conic gradient in CSS or fall back to first stop color. + // For now, use the first stop color as a fallback. + log::warn!( + "Sweep gradient at ({}, {}) from {}° to {}° - using fallback", + c0.x, + c0.y, + start_angle, + end_angle + ); + + if let Some(first_stop) = stops.first() { + self.paint_solid(first_stop.palette_index, first_stop.alpha); + } + + // Consume extend to suppress unused warning + let _ = extend; + } + + fn write_spread_method(&mut self, extend: skrifa::color::Extend) { + let method = match extend { + skrifa::color::Extend::Pad => "pad", + skrifa::color::Extend::Repeat => "repeat", + skrifa::color::Extend::Reflect => "reflect", + _ => "pad", // Default to pad for unknown values + }; + self.svg.write_attribute("spreadMethod", &method); + } + + fn write_gradient_stops(&mut self, stops: &[skrifa::color::ColorStop]) { + for stop in stops { + self.svg.start_element("stop"); + self.svg.write_attribute("offset", &stop.offset); + self.write_color("stop-color", stop.palette_index, stop.alpha); + self.svg.end_element(); + } + } + + fn clip_with_path(&mut self, path: &str) { + let clip_id = format!("cp{}", self.clip_path_index); + self.clip_path_index += 1; + + self.svg.start_element("clipPath"); + self.svg.write_attribute("id", &clip_id); + self.svg.start_element("path"); + self.write_transform("transform", self.current_transform); + self.svg.write_attribute("d", &path); + self.svg.end_element(); + self.svg.end_element(); + + self.svg.start_element("g"); + self.svg + .write_attribute_fmt("clip-path", format_args!("url(#{})", clip_id)); + } +} + +impl<'a> ColorPainter for SkrifaGlyphPainter<'a> { + fn push_transform(&mut self, transform: skrifa::color::Transform) { + self.transform_stack.push(self.current_transform); + self.current_transform = self.current_transform * transform; + } + + fn pop_transform(&mut self) { + if let Some(ts) = self.transform_stack.pop() { + self.current_transform = ts; + } + } + + fn push_clip_glyph(&mut self, glyph_id: GlyphId) { + self.path_buf.clear(); + let outlines = self.font.outline_glyphs(); + if let Some(glyph) = outlines.get(glyph_id) { + let mut pen = SvgPathPen::new(self.path_buf); + let settings = skrifa::outline::DrawSettings::unhinted( + skrifa::instance::Size::unscaled(), + LocationRef::default(), + ); + let _ = glyph.draw(settings, &mut pen); + pen.finish(); + } + self.clip_with_path(&self.path_buf.clone()); + } + + fn push_clip_box(&mut self, clip_box: BoundingBox) { + let x_min = clip_box.x_min; + let x_max = clip_box.x_max; + let y_min = clip_box.y_min; + let y_max = clip_box.y_max; + + let clip_path = format!( + "M {} {} L {} {} L {} {} L {} {} Z", + x_min, y_min, x_max, y_min, x_max, y_max, x_min, y_max + ); + + self.clip_with_path(&clip_path); + } + + fn pop_clip(&mut self) { + self.svg.end_element(); // g with clip-path + } + + fn fill(&mut self, brush: Brush<'_>) { + match brush { + Brush::Solid { + palette_index, + alpha, + } => { + self.paint_solid(palette_index, alpha); + } + Brush::LinearGradient { + p0, + p1, + color_stops, + extend, + } => { + self.paint_linear_gradient(p0, p1, color_stops, extend); + } + Brush::RadialGradient { + c0, + r0, + c1, + r1, + color_stops, + extend, + } => { + self.paint_radial_gradient(c0, r0, c1, r1, color_stops, extend); + } + Brush::SweepGradient { + c0, + start_angle, + end_angle, + color_stops, + extend, + } => { + self.paint_sweep_gradient(c0, start_angle, end_angle, color_stops, extend); + } + } + } + + fn push_layer(&mut self, mode: CompositeMode) { + self.svg.start_element("g"); + + let mode_str = match mode { + CompositeMode::SrcOver => "normal", + CompositeMode::Screen => "screen", + CompositeMode::Overlay => "overlay", + CompositeMode::Darken => "darken", + CompositeMode::Lighten => "lighten", + CompositeMode::ColorDodge => "color-dodge", + CompositeMode::ColorBurn => "color-burn", + CompositeMode::HardLight => "hard-light", + CompositeMode::SoftLight => "soft-light", + CompositeMode::Difference => "difference", + CompositeMode::Exclusion => "exclusion", + CompositeMode::Multiply => "multiply", + CompositeMode::HslHue => "hue", + CompositeMode::HslSaturation => "saturation", + CompositeMode::HslColor => "color", + CompositeMode::HslLuminosity => "luminosity", + _ => { + log::warn!("Unsupported blend mode: {:?}", mode); + "normal" + } + }; + self.svg.write_attribute_fmt( + "style", + format_args!("mix-blend-mode: {}; isolation: isolate", mode_str), + ); + } + + fn pop_layer(&mut self) { + self.svg.end_element(); // g + } +} + +/// Paint a COLR glyph using skrifa's ColorPainter and return the resulting SVG tree. +pub(crate) fn paint_colr_glyph(data: &[u8], face_index: u32, glyph_id: GlyphId) -> Option { + let font = FontRef::from_index(data, face_index).ok()?; + + let mut svg = XmlWriter::new(xmlwriter::Options::default()); + + svg.start_element("svg"); + svg.write_attribute("xmlns", "http://www.w3.org/2000/svg"); + svg.write_attribute("xmlns:xlink", "http://www.w3.org/1999/xlink"); + + let mut path_buf = String::with_capacity(256); + + svg.start_element("g"); + + let color_glyphs = font.color_glyphs(); + + // Try COLRv1 first, then fall back to COLRv0 + let color_glyph = color_glyphs + .get_with_format(glyph_id, ColorGlyphFormat::ColrV1) + .or_else(|| color_glyphs.get_with_format(glyph_id, ColorGlyphFormat::ColrV0))?; + + let mut painter = SkrifaGlyphPainter::new(font, &mut svg, &mut path_buf); + + // Paint the glyph - this calls our ColorPainter implementation + let _ = color_glyph.paint(LocationRef::default(), &mut painter); + + svg.end_element(); // g + + Tree::from_data(svg.end_document().as_bytes(), &Options::default()).ok() +} diff --git a/crates/usvg/src/text/skrifa_metrics.rs b/crates/usvg/src/text/skrifa_metrics.rs new file mode 100644 index 000000000..e3bab2de7 --- /dev/null +++ b/crates/usvg/src/text/skrifa_metrics.rs @@ -0,0 +1,104 @@ +// Copyright 2026 the Resvg Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Font metrics extraction using skrifa. +//! +//! This module provides font metrics extraction using skrifa's MetadataProvider trait, +//! replacing the previous ttf-parser based implementation. + +use std::num::NonZeroU16; + +use fontdb::ID; +use skrifa::{ + FontRef, MetadataProvider, instance::LocationRef, instance::Size as SkrifaSize, + raw::TableProvider, +}; + +use super::layout::ResolvedFont; + +/// Load font metrics using skrifa's MetadataProvider. +/// +/// Returns a ResolvedFont containing all necessary metrics for text layout. +pub(crate) fn load_font_metrics(data: &[u8], face_index: u32, id: ID) -> Option { + let font = FontRef::from_index(data, face_index).ok()?; + let metrics = font.metrics(SkrifaSize::unscaled(), LocationRef::default()); + + let units_per_em = NonZeroU16::new(metrics.units_per_em)?; + + // skrifa provides ascent/descent as f32 in font units (when using unscaled size) + let ascent = metrics.ascent as i16; + let descent = metrics.descent as i16; + + // x_height is optional in skrifa + let x_height = metrics + .x_height + .and_then(|x| u16::try_from(x as i32).ok()) + .and_then(NonZeroU16::new); + let x_height = match x_height { + Some(height) => height, + None => { + // If not set - fallback to height * 45%. + // 45% is what Firefox uses. + u16::try_from((f32::from(ascent - descent) * 0.45) as i32) + .ok() + .and_then(NonZeroU16::new)? + } + }; + + // Get strikeout/line-through position from skrifa's strikeout decoration + let line_through_position = match metrics.strikeout { + Some(decoration) => decoration.offset as i16, + None => x_height.get() as i16 / 2, + }; + + // Get underline metrics from skrifa + let (underline_position, underline_thickness) = match metrics.underline { + Some(decoration) => { + let thickness = u16::try_from(decoration.thickness as i32) + .ok() + .and_then(NonZeroU16::new) + // skrifa guarantees that units_per_em is >= 16 + .unwrap_or_else(|| NonZeroU16::new(units_per_em.get() / 12).unwrap()); + + (decoration.offset as i16, thickness) + } + None => ( + -(units_per_em.get() as i16) / 9, + NonZeroU16::new(units_per_em.get() / 12).unwrap(), + ), + }; + + // Get subscript/superscript metrics from OS/2 table, fall back to calculation + // 0.2 and 0.4 are generic offsets used by some applications (Inkscape/librsvg). + let mut subscript_offset = (units_per_em.get() as f32 / 0.2).round() as i16; + let mut superscript_offset = (units_per_em.get() as f32 / 0.4).round() as i16; + + // Try to get actual values from OS/2 table + if let Ok(os2) = font.os2() { + subscript_offset = os2.y_subscript_y_offset(); + superscript_offset = os2.y_superscript_y_offset(); + } + + Some(ResolvedFont::new( + id, + units_per_em, + ascent, + descent, + x_height, + underline_position, + underline_thickness, + line_through_position, + subscript_offset, + superscript_offset, + )) +} + +/// Check if a font contains a glyph for the given character using skrifa's charmap. +pub(crate) fn has_char(data: &[u8], face_index: u32, c: char) -> bool { + let font = match FontRef::from_index(data, face_index) { + Ok(f) => f, + Err(_) => return false, + }; + + font.charmap().map(c).is_some() +} diff --git a/crates/usvg/tests/files/clip-path-with-complex-text-expected.svg b/crates/usvg/tests/files/clip-path-with-complex-text-expected.svg index 49f2ea86e..c5fb10474 100644 --- a/crates/usvg/tests/files/clip-path-with-complex-text-expected.svg +++ b/crates/usvg/tests/files/clip-path-with-complex-text-expected.svg @@ -2,7 +2,7 @@ - + diff --git a/crates/usvg/tests/files/clip-path-with-text-expected.svg b/crates/usvg/tests/files/clip-path-with-text-expected.svg index 01f09f81c..3de7dcd08 100644 --- a/crates/usvg/tests/files/clip-path-with-text-expected.svg +++ b/crates/usvg/tests/files/clip-path-with-text-expected.svg @@ -1,7 +1,7 @@ - + diff --git a/crates/usvg/tests/files/preserve-text-simple-case-expected.svg b/crates/usvg/tests/files/preserve-text-simple-case-expected.svg index 2a97df767..2fa81f83c 100644 --- a/crates/usvg/tests/files/preserve-text-simple-case-expected.svg +++ b/crates/usvg/tests/files/preserve-text-simple-case-expected.svg @@ -1,5 +1,5 @@ - + diff --git a/crates/usvg/tests/files/text-simple-case-expected.svg b/crates/usvg/tests/files/text-simple-case-expected.svg index 2a97df767..2fa81f83c 100644 --- a/crates/usvg/tests/files/text-simple-case-expected.svg +++ b/crates/usvg/tests/files/text-simple-case-expected.svg @@ -1,5 +1,5 @@ - + diff --git a/crates/usvg/tests/files/text-with-generated-gradients-expected.svg b/crates/usvg/tests/files/text-with-generated-gradients-expected.svg index 8af171f67..9a0e75383 100644 --- a/crates/usvg/tests/files/text-with-generated-gradients-expected.svg +++ b/crates/usvg/tests/files/text-with-generated-gradients-expected.svg @@ -6,8 +6,8 @@ - - - + + +