Migrate fonts to be stored as resources (#4165)

* Good start

* Impl

* allow responses.add(async { Message::NoOp });

* Fix

* Cache font blob in text node

* Refactor font handling to use Font struct direcly

* Fix fmt

* Embedd Font Resources by default

* Review

* Review

* Cache font info based on ResourceHash
This commit is contained in:
Timon
2026-06-03 09:21:51 +00:00
committed by Keavon Chambers
parent 7b9c480a8d
commit cd8ea1f554
46 changed files with 838 additions and 703 deletions

View File

@@ -11,7 +11,6 @@ use graph_craft::graphene_compiler::Compiler;
use graph_craft::proto::ProtoNetwork;
use graph_craft::util::load_network;
use graphene_std::application_io::{ApplicationIo, NodeGraphUpdateMessage, NodeGraphUpdateSender};
use graphene_std::text::FontCache;
use interpreted_executor::dynamic_executor::DynamicExecutor;
use interpreted_executor::util::wrap_network_in_scope;
use std::error::Error;
@@ -134,7 +133,6 @@ async fn main() -> Result<(), Box<dyn Error>> {
max_render_region_size: EditorPreferences::default().max_render_region_size,
};
let editor_api = Arc::new(PlatformEditorApi {
font_cache: FontCache::default(),
application_io: Some(application_io_for_api),
node_graph_message_sender: Box::new(UpdateLogger {}),
editor_preferences: Box::new(preferences),

View File

@@ -6,7 +6,6 @@ use std::hash::{Hash, Hasher};
use std::ptr::addr_of;
use std::sync::Arc;
use std::time::Duration;
use text_nodes::FontCache;
use vector_types::vector::style::RenderMode;
pub use graphene_resource as resource;
@@ -126,8 +125,6 @@ impl GetEditorPreferences for DummyPreferences {
}
pub struct EditorApi<Io> {
/// Font data (for rendering text) made available to the graph through the `PlatformEditorApi`.
pub font_cache: FontCache,
/// Gives access to APIs like resources.
pub application_io: Option<Arc<Io>>,
pub node_graph_message_sender: Box<dyn NodeGraphUpdateSender + Send + Sync>,
@@ -140,7 +137,6 @@ impl<Io> Eq for EditorApi<Io> {}
impl<Io: Default> Default for EditorApi<Io> {
fn default() -> Self {
Self {
font_cache: FontCache::default(),
application_io: None,
node_graph_message_sender: Box::new(Logger),
editor_preferences: Box::new(DummyPreferences),
@@ -150,7 +146,6 @@ impl<Io: Default> Default for EditorApi<Io> {
impl<Io> Hash for EditorApi<Io> {
fn hash<H: Hasher>(&self, state: &mut H) {
self.font_cache.hash(state);
self.application_io.as_ref().map_or(0, |io| io as *const _ as usize).hash(state);
(self.node_graph_message_sender.as_ref() as *const dyn NodeGraphUpdateSender).hash(state);
(self.editor_preferences.as_ref() as *const dyn GetEditorPreferences).hash(state);
@@ -165,8 +160,7 @@ impl<Io> core_types::graphene_hash::CacheHash for EditorApi<Io> {
impl<Io> PartialEq for EditorApi<Io> {
fn eq(&self, other: &Self) -> bool {
self.font_cache == other.font_cache
&& self.application_io.as_ref().map_or(0, |io| addr_of!(io) as usize) == other.application_io.as_ref().map_or(0, |io| addr_of!(io) as usize)
self.application_io.as_ref().map_or(0, |io| addr_of!(io) as usize) == other.application_io.as_ref().map_or(0, |io| addr_of!(io) as usize)
&& std::ptr::eq(self.node_graph_message_sender.as_ref() as *const _, other.node_graph_message_sender.as_ref() as *const _)
&& std::ptr::eq(self.editor_preferences.as_ref() as *const _, other.editor_preferences.as_ref() as *const _)
}
@@ -174,7 +168,7 @@ impl<Io> PartialEq for EditorApi<Io> {
impl<T> Debug for EditorApi<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EditorApi").field("font_cache", &self.font_cache).finish()
f.debug_struct("EditorApi").finish()
}
}

View File

@@ -26,6 +26,10 @@ impl Resource {
pub fn hash(&self) -> ResourceHash {
self.hash
}
pub fn empty() -> Self {
Self::new([])
}
}
impl From<&Resource> for Arc<dyn AsRef<[u8]> + Send + Sync> {
@@ -67,7 +71,7 @@ impl CacheHash for Resource {
}
/// Blake3 content hash of a resource, represented as 32 bytes
#[derive(Clone, Copy, Debug, Default, Hash, PartialEq, Eq, PartialOrd, Ord, DynAny)]
#[derive(Clone, Copy, Default, Hash, PartialEq, Eq, PartialOrd, Ord, DynAny)]
pub struct ResourceHash([u8; 32]);
impl From<&[u8]> for ResourceHash {
@@ -135,6 +139,12 @@ impl std::str::FromStr for ResourceHash {
}
}
impl std::fmt::Debug for ResourceHash {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&String::from(self))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResourceHashParseError {
InvalidLength { found: usize },
@@ -238,6 +248,18 @@ impl ResourceId {
}
}
impl From<u64> for ResourceId {
fn from(value: u64) -> Self {
Self(value)
}
}
impl From<ResourceId> for u64 {
fn from(id: ResourceId) -> Self {
id.0
}
}
impl std::fmt::Display for ResourceId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)

View File

@@ -1,23 +1,21 @@
use core_types::Ctx;
use core_types::list::List;
use graph_craft::application_io::PlatformEditorApi;
use graph_craft::application_io::resource::Resource;
use graphic_types::Vector;
pub use text_nodes::*;
/// Draws a text string as vector geometry with a choice of font and styling.
#[node_macro::node(category("Text"))]
fn text<'i: 'n>(
fn text(
_: impl Ctx,
/// The Graphite editor's source for global font resources.
#[scope("editor-api")]
editor_resources: &'i PlatformEditorApi,
_primary: (),
/// The text content to be drawn.
#[widget(ParsedWidgetOverride::Custom = "text_area")]
#[default("Lorem ipsum")]
text: String,
/// The typeface used to draw the text.
/// The loaded font file used to draw the text. The editor resolves the chosen typeface to these bytes via the resource system.
#[widget(ParsedWidgetOverride::Custom = "text_font")]
font: Font,
font: Resource,
/// The font size used to draw the text.
#[unit(" px")]
#[default(24.)]
@@ -73,5 +71,5 @@ fn text<'i: 'n>(
align,
};
to_path(&text, &font, &editor_resources.font_cache, typesetting, separate_glyphs)
to_path(&text, &font, typesetting, separate_glyphs)
}

View File

@@ -15,6 +15,7 @@ wasm = ["core-types/wasm", "tsify", "wasm-bindgen"]
# Local dependencies
core-types = { workspace = true }
graphene-hash = { workspace = true }
graphene-resource = { workspace = true }
raster-types = { workspace = true }
vector-types = { workspace = true }
node-macro = { workspace = true }

View File

@@ -0,0 +1,71 @@
use core_types::graphene_hash::CacheHash;
use dyn_any::DynAny;
/// A font type (storing font family and font style)
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Debug, Clone, Eq, DynAny)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Font {
#[cfg_attr(feature = "serde", serde(rename = "fontFamily"))]
pub font_family: String,
#[cfg_attr(feature = "serde", serde(rename = "fontStyle", deserialize_with = "migrate_font_style"))]
pub font_style: String,
}
impl std::hash::Hash for Font {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.font_family.hash(state);
self.font_style.hash(state);
}
}
impl CacheHash for Font {
fn cache_hash<H: core::hash::Hasher>(&self, state: &mut H) {
self.font_family.cache_hash(state);
self.font_style.cache_hash(state);
}
}
impl PartialEq for Font {
fn eq(&self, other: &Self) -> bool {
self.font_family == other.font_family && self.font_style == other.font_style
}
}
impl Font {
pub fn new(font_family: String, font_style: String) -> Self {
Self { font_family, font_style }
}
pub fn new_with_default_style(font_family: String) -> Self {
Self::new(font_family, core_types::consts::DEFAULT_FONT_STYLE.into())
}
pub fn named_weight(weight: u32) -> &'static str {
// From https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight#common_weight_name_mapping
match weight {
100 => "Thin",
200 => "Extra Light",
300 => "Light",
400 => "Regular",
500 => "Medium",
600 => "Semi Bold",
700 => "Bold",
800 => "Extra Bold",
900 => "Black",
950 => "Extra Black",
_ => "Regular",
}
}
}
impl Default for Font {
fn default() -> Self {
Self::new(core_types::consts::DEFAULT_FONT_FAMILY.into(), core_types::consts::DEFAULT_FONT_STYLE.into())
}
}
// TODO: Eventually remove this migration document upgrade code
fn migrate_font_style<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
use serde::Deserialize;
String::deserialize(deserializer).map(|name| if name == "Normal (400)" { "Regular (400)".to_string() } else { name })
}

View File

@@ -1,142 +0,0 @@
use core_types::graphene_hash::CacheHash;
use dyn_any::DynAny;
use parley::fontique::Blob;
use std::collections::HashMap;
use std::sync::Arc;
/// A font type (storing font family and font style and an optional preview URL)
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Debug, Clone, Eq, DynAny)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Font {
#[cfg_attr(feature = "serde", serde(rename = "fontFamily"))]
pub font_family: String,
#[cfg_attr(feature = "serde", serde(rename = "fontStyle", deserialize_with = "migrate_font_style"))]
pub font_style: String,
#[cfg_attr(feature = "serde", serde(skip))]
pub font_style_to_restore: Option<String>,
}
impl std::hash::Hash for Font {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.font_family.hash(state);
self.font_style.hash(state);
// Don't consider `font_style_to_restore` in the HashMaps
}
}
impl CacheHash for Font {
fn cache_hash<H: core::hash::Hasher>(&self, state: &mut H) {
self.font_family.cache_hash(state);
self.font_style.cache_hash(state);
// Don't consider `font_style_to_restore` in the HashMaps
}
}
impl PartialEq for Font {
fn eq(&self, other: &Self) -> bool {
// Don't consider `font_style_to_restore` in the HashMaps
self.font_family == other.font_family && self.font_style == other.font_style
}
}
impl Font {
pub fn new(font_family: String, font_style: String) -> Self {
Self {
font_family,
font_style,
font_style_to_restore: None,
}
}
pub fn named_weight(weight: u32) -> &'static str {
// From https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight#common_weight_name_mapping
match weight {
100 => "Thin",
200 => "Extra Light",
300 => "Light",
400 => "Regular",
500 => "Medium",
600 => "Semi Bold",
700 => "Bold",
800 => "Extra Bold",
900 => "Black",
950 => "Extra Black",
_ => "Regular",
}
}
}
impl Default for Font {
fn default() -> Self {
Self::new(core_types::consts::DEFAULT_FONT_FAMILY.into(), core_types::consts::DEFAULT_FONT_STYLE.into())
}
}
/// A cache of all loaded font data and preview urls along with the default font (send from `init_app` in `editor_api.rs`)
#[derive(Clone, Default, DynAny)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct FontCache {
/// Actual font file data used for rendering a font
font_file_data: HashMap<Font, Vec<u8>>,
}
impl std::fmt::Debug for FontCache {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FontCache").field("font_file_data", &self.font_file_data.keys().collect::<Vec<_>>()).finish()
}
}
impl std::hash::Hash for FontCache {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.font_file_data.len().hash(state);
self.font_file_data.keys().for_each(|font| font.hash(state));
}
}
impl PartialEq for FontCache {
fn eq(&self, other: &Self) -> bool {
if self.font_file_data.len() != other.font_file_data.len() {
return false;
}
self.font_file_data.keys().all(|font| other.font_file_data.contains_key(font))
}
}
impl FontCache {
/// Returns the font family name if the font is cached, otherwise returns the fallback font family name if that is cached
pub fn resolve_font<'a>(&'a self, font: &'a Font) -> Option<&'a Font> {
if self.font_file_data.contains_key(font) {
Some(font)
} else {
self.font_file_data
.keys()
.find(|font| font.font_family == core_types::consts::DEFAULT_FONT_FAMILY && font.font_style == core_types::consts::DEFAULT_FONT_STYLE)
}
}
/// Try to get the bytes for a font
pub fn get<'a>(&'a self, font: &'a Font) -> Option<(&'a Vec<u8>, &'a Font)> {
self.resolve_font(font).and_then(|font| self.font_file_data.get(font).map(|data| (data, font)))
}
/// Get font data as a Blob for use with parley/skrifa
pub fn get_blob<'a>(&'a self, font: &'a Font) -> Option<(Blob<u8>, &'a Font)> {
self.get(font).map(|(data, font)| (Blob::new(Arc::new(data.clone())), font))
}
/// Check if the font is already loaded
pub fn loaded_font(&self, font: &Font) -> bool {
self.font_file_data.contains_key(font)
}
/// Insert a new font into the cache
pub fn insert(&mut self, font: Font, data: Vec<u8>) {
self.font_file_data.insert(font.clone(), data);
}
}
// TODO: Eventually remove this migration document upgrade code
fn migrate_font_style<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
use serde::Deserialize;
String::deserialize(deserializer).map(|name| if name == "Normal (400)" { "Regular (400)".to_string() } else { name })
}

View File

@@ -1,4 +1,4 @@
mod font_cache;
mod font;
pub mod json;
mod path_builder;
pub mod regex;
@@ -16,7 +16,7 @@ use unicode_segmentation::UnicodeSegmentation;
// Re-export for convenience
pub use core_types as gcore;
pub use font_cache::*;
pub use font::*;
pub use text_context::TextContext;
pub use to_path::*;
pub use vector_types;

View File

@@ -1,7 +1,8 @@
use super::{Font, FontCache, TypesettingConfig};
use super::TypesettingConfig;
use core::cell::RefCell;
use core_types::list::List;
use glam::DVec2;
use graphene_resource::{Resource, ResourceHash};
use parley::fontique::{Blob, FamilyId, FontInfo};
use parley::{AlignmentOptions, FontContext, Layout, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty};
use std::collections::HashMap;
@@ -19,8 +20,7 @@ thread_local! {
pub struct TextContext {
font_context: FontContext,
layout_context: LayoutContext<()>,
/// Cached font metadata for performance optimization
font_info_cache: HashMap<Font, (FamilyId, FontInfo)>,
font_info_cache: HashMap<ResourceHash, (FamilyId, FontInfo)>,
}
impl TextContext {
@@ -32,40 +32,30 @@ impl TextContext {
THREAD_TEXT.with_borrow_mut(f)
}
/// Resolve a font and return its data as a Blob if available
fn resolve_font_data<'a>(&self, font: &'a Font, font_cache: &'a FontCache) -> Option<(Blob<u8>, &'a Font)> {
font_cache.get_blob(font)
}
/// Get or cache font information for a given font
fn get_font_info(&mut self, font: &Font, font_data: &Blob<u8>) -> Option<(String, FontInfo)> {
// Check if we already have the font info cached
if let Some((family_id, font_info)) = self.font_info_cache.get(font)
/// Get or cache font information for the given font resource.
fn get_font_info(&mut self, font: &Resource) -> Option<(String, FontInfo)> {
let hash = font.hash();
if let Some((family_id, font_info)) = self.font_info_cache.get(&hash)
&& let Some(family_name) = self.font_context.collection.family_name(*family_id)
{
return Some((family_name.to_string(), font_info.clone()));
}
// Register the font and cache the info
let families = self.font_context.collection.register_fonts(font_data.clone(), None);
let families = self.font_context.collection.register_fonts(Blob::new(font.into()), None);
families.first().and_then(|(family_id, fonts_info)| {
fonts_info.first().and_then(|font_info| {
self.font_context.collection.family_name(*family_id).map(|family_name| {
// Cache the font info for future use
self.font_info_cache.insert(font.clone(), (*family_id, font_info.clone()));
self.font_info_cache.insert(hash, (*family_id, font_info.clone()));
(family_name.to_string(), font_info.clone())
})
})
})
}
/// Create a text layout using the specified font and typesetting configuration
fn layout_text(&mut self, text: &str, font: &Font, font_cache: &FontCache, typesetting: TypesettingConfig) -> Option<Layout<()>> {
// Note that the actual_font may not be the desired font if that font is not yet loaded.
// It is important not to cache the default font under the name of another font.
let (font_data, actual_font) = self.resolve_font_data(font, font_cache)?;
let (font_family, font_info) = self.get_font_info(actual_font, &font_data)?;
/// Create a text layout from the given font resource and typesetting configuration.
fn layout_text(&mut self, text: &str, font: &Resource, typesetting: TypesettingConfig) -> Option<Layout<()>> {
let (font_family, font_info) = self.get_font_info(font)?;
const DISPLAY_SCALE: f32 = 1.;
let mut builder = self.layout_context.ranged_builder(&mut self.font_context, text, DISPLAY_SCALE, false);
@@ -89,8 +79,8 @@ impl TextContext {
}
/// Convert text to vector paths using the specified font and typesetting configuration
pub fn to_path(&mut self, text: &str, font: &Font, font_cache: &FontCache, typesetting: TypesettingConfig, per_glyph_items: bool) -> List<Vector> {
let Some(layout) = self.layout_text(text, font, font_cache, typesetting) else {
pub fn to_path(&mut self, text: &str, font: &Resource, typesetting: TypesettingConfig, per_glyph_items: bool) -> List<Vector> {
let Some(layout) = self.layout_text(text, font, typesetting) else {
return List::new_from_element(Vector::default());
};
@@ -162,8 +152,8 @@ impl TextContext {
}
/// Calculate the bounding box of text using the specified font and typesetting configuration
pub fn bounding_box(&mut self, text: &str, font: &Font, font_cache: &FontCache, typesetting: TypesettingConfig, for_clipping_test: bool) -> DVec2 {
let Some(layout) = self.layout_text(text, font, font_cache, typesetting) else {
pub fn bounding_box(&mut self, text: &str, font: &Resource, typesetting: TypesettingConfig, for_clipping_test: bool) -> DVec2 {
let Some(layout) = self.layout_text(text, font, typesetting) else {
return DVec2::ZERO;
};
@@ -181,9 +171,9 @@ impl TextContext {
}
/// Check if text lines are being clipped due to height constraints
pub fn lines_clipping(&mut self, text: &str, font: &Font, font_cache: &FontCache, typesetting: TypesettingConfig) -> bool {
pub fn lines_clipping(&mut self, text: &str, font: &Resource, typesetting: TypesettingConfig) -> bool {
let Some(max_height) = typesetting.max_height else { return false };
let bounds = self.bounding_box(text, font, font_cache, typesetting, true);
let bounds = self.bounding_box(text, font, typesetting, true);
max_height < bounds.y
}
}

View File

@@ -1,23 +1,18 @@
use super::TypesettingConfig;
use super::text_context::TextContext;
use super::{Font, FontCache, TypesettingConfig};
use core_types::list::List;
use glam::DVec2;
use parley::fontique::Blob;
use std::sync::Arc;
use graphene_resource::Resource;
use vector_types::Vector;
pub fn to_path(text: &str, font: &Font, font_cache: &FontCache, typesetting: TypesettingConfig, per_glyph_items: bool) -> List<Vector> {
TextContext::with_thread_local(|ctx| ctx.to_path(text, font, font_cache, typesetting, per_glyph_items))
pub fn to_path(text: &str, font: &Resource, typesetting: TypesettingConfig, per_glyph_items: bool) -> List<Vector> {
TextContext::with_thread_local(|ctx| ctx.to_path(text, font, typesetting, per_glyph_items))
}
pub fn bounding_box(text: &str, font: &Font, font_cache: &FontCache, typesetting: TypesettingConfig, for_clipping_test: bool) -> DVec2 {
TextContext::with_thread_local(|ctx| ctx.bounding_box(text, font, font_cache, typesetting, for_clipping_test))
pub fn bounding_box(text: &str, font: &Resource, typesetting: TypesettingConfig, for_clipping_test: bool) -> DVec2 {
TextContext::with_thread_local(|ctx| ctx.bounding_box(text, font, typesetting, for_clipping_test))
}
pub fn load_font(data: &[u8]) -> Blob<u8> {
Blob::new(Arc::new(data.to_vec()))
}
pub fn lines_clipping(text: &str, font: &Font, font_cache: &FontCache, typesetting: TypesettingConfig) -> bool {
TextContext::with_thread_local(|ctx| ctx.lines_clipping(text, font, font_cache, typesetting))
pub fn lines_clipping(text: &str, font: &Resource, typesetting: TypesettingConfig) -> bool {
TextContext::with_thread_local(|ctx| ctx.lines_clipping(text, font, typesetting))
}