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-05 13:27:25 -04:00
committed by Keavon Chambers
parent 7b9c480a8d
commit cd8ea1f554
46 changed files with 838 additions and 703 deletions
@@ -21,7 +21,7 @@ use crate::messages::portfolio::document::properties_panel::properties_panel_mes
use crate::messages::portfolio::document::utility_types::document_metadata::{DocumentMetadata, LayerNodeIdentifier};
use crate::messages::portfolio::document::utility_types::misc::{AlignAggregate, AlignAxis, FlipAxis, PTZ};
use crate::messages::portfolio::document::utility_types::network_interface::{FlowType, InputConnector, NodeTemplate, OutputConnector};
use crate::messages::portfolio::utility_types::{CachedData, PanelType};
use crate::messages::portfolio::utility_types::PanelType;
use crate::messages::prelude::*;
use crate::messages::tool::common_functionality::graph_modification_utils::{self, get_blend_mode, get_fill, get_opacity};
use crate::messages::tool::tool_messages::select_tool::SelectToolPointerKeys;
@@ -52,7 +52,6 @@ use std::time::Duration;
pub struct DocumentMessageContext<'a> {
pub document_id: DocumentId,
pub ipp: &'a InputPreprocessorMessageHandler,
pub cached_data: &'a CachedData,
pub executor: &'a mut NodeGraphExecutor,
pub current_tool: &'a ToolType,
pub preferences: &'a PreferencesMessageHandler,
@@ -61,6 +60,7 @@ pub struct DocumentMessageContext<'a> {
pub properties_panel_open: bool,
pub viewport: &'a ViewportMessageHandler,
pub resource_storage: &'a ResourceStorageMessageHandler,
pub fonts: &'a FontsMessageHandler,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, ExtractField)]
@@ -198,7 +198,6 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
let DocumentMessageContext {
document_id,
ipp,
cached_data,
executor,
viewport,
current_tool,
@@ -207,6 +206,7 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
layers_panel_open,
properties_panel_open,
resource_storage,
fonts,
} = context;
match message {
@@ -233,11 +233,12 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
}
DocumentMessage::PropertiesPanel(message) => {
let context = PropertiesPanelMessageContext {
executor,
network_interface: &mut self.network_interface,
resources: &self.resources,
selection_network_path: &self.selection_network_path,
document_name: self.name.as_str(),
executor,
cached_data,
fonts,
properties_panel_open,
};
self.properties_panel_message_handler.process_message(message, responses, context);
@@ -282,7 +283,7 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
graph_operation_message_handler.process_message(message, responses, context);
}
DocumentMessage::Resource(message) => {
let context = ResourceMessageContext {};
let context = ResourceMessageContext { document_id, fonts };
self.resources.process_message(message, responses, context);
}
DocumentMessage::AlignSelectedLayers { axis, aggregate } => {
@@ -933,21 +934,19 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
let mut document = self.clone();
let resources_load_handle = resource_storage.resources();
responses.add(FutureMessage::Await {
future: MessageFuture::new(async move {
document.resources.garbage_collect(document.used_resources(false).as_ref());
document.resources.embed_resources(resources_load_handle).await;
responses.add(async move {
document.resources.collect_garbage(document.used_resources(false).as_ref());
document.resources.embed_resources(resources_load_handle).await;
let content = document.serialize_document().into_bytes().into();
let content = document.serialize_document().into_bytes().into();
Message::Frontend(FrontendMessage::TriggerSaveDocument {
document_id,
name,
path,
folder,
content,
})
}),
Message::Frontend(FrontendMessage::TriggerSaveDocument {
document_id,
name,
path,
folder,
content,
})
});
}
DocumentMessage::SavedDocument { path } => {
@@ -2658,23 +2657,6 @@ impl DocumentMessageHandler {
}
}
/// Loads all of the fonts in the document.
pub fn load_layer_resources(&self, responses: &mut VecDeque<Message>) {
let mut fonts_to_load = HashSet::new();
for (_, node, _) in self.document_network().recursive_nodes() {
for input in &node.inputs {
if let Some(TaggedValue::Font(font)) = input.as_value() {
fonts_to_load.insert(font.clone());
}
}
}
for font in fonts_to_load {
responses.add(PortfolioMessage::LoadFontData { font });
}
}
pub fn update_document_widgets(&self, responses: &mut VecDeque<Message>, animation_is_playing: bool, time: Duration) {
let mut snapping_state = self.snapping_state.clone();
let mut snapping_state2 = self.snapping_state.clone();
@@ -3470,7 +3452,7 @@ impl DocumentMessageHandler {
pub fn garbage_collect_resources(&mut self) {
let used_resources = self.used_resources(true);
self.resources.garbage_collect(&used_resources);
self.resources.collect_garbage(&used_resources);
}
pub fn used_resources(&self, include_history: bool) -> Box<[ResourceId]> {
@@ -249,12 +249,13 @@ impl<'a> ModifyInputsContext<'a> {
}
pub fn insert_text(&mut self, text: String, font: Font, typesetting: TypesettingConfig, layer: LayerNodeIdentifier) {
let font_resource_id = ResourceId::new();
let text = resolve_proto_node_type(graphene_std::text::text::IDENTIFIER)
.expect("Text node does not exist")
.node_template_input_override([
Some(NodeInput::scope("editor-api")),
Some(NodeInput::value(TaggedValue::None, false)),
Some(NodeInput::value(TaggedValue::String(text), false)),
Some(NodeInput::value(TaggedValue::Font(font), false)),
Some(NodeInput::value(TaggedValue::Resource(font_resource_id), false)),
Some(NodeInput::value(TaggedValue::F64(typesetting.font_size), false)),
Some(NodeInput::value(TaggedValue::F64(typesetting.line_height_ratio), false)),
Some(NodeInput::value(TaggedValue::F64(typesetting.character_spacing), false)),
@@ -264,6 +265,7 @@ impl<'a> ModifyInputsContext<'a> {
Some(NodeInput::value(TaggedValue::F64(typesetting.max_height.unwrap_or(100.)), false)),
Some(NodeInput::value(TaggedValue::F64(typesetting.tilt), false)),
Some(NodeInput::value(TaggedValue::TextAlign(typesetting.align), false)),
Some(NodeInput::value(TaggedValue::Bool(false), false)),
]);
let transform = resolve_proto_node_type(graphene_std::transform_nodes::transform::IDENTIFIER)
.expect("Transform node does not exist")
@@ -276,6 +278,8 @@ impl<'a> ModifyInputsContext<'a> {
self.network_interface.insert_node(text_id, text, &[]);
self.network_interface.move_node_to_chain_start(&text_id, layer, &[], self.import);
self.responses.add(DocumentMessage::Resource(ResourceMessage::AddFont { resource_id: font_resource_id, font }));
let transform_id = NodeId::new();
self.network_interface.insert_node(transform_id, transform, &[]);
self.network_interface.move_node_to_chain_start(&transform_id, layer, &[], self.import);
@@ -8,8 +8,7 @@ use crate::messages::portfolio::document::utility_types::network_interface::{
DocumentNodeMetadata, DocumentNodePersistentMetadata, InputMetadata, NodeNetworkInterface, NodeNetworkMetadata, NodeNetworkPersistentMetadata, NodeTemplate, NodeTypePersistentMetadata,
Vec2InputSettings, WidgetOverride,
};
use crate::messages::portfolio::utility_types::CachedData;
use crate::messages::prelude::Message;
use crate::messages::prelude::{FontsMessageHandler, Message, ResourceMessageHandler};
use crate::node_graph_executor::NodeGraphExecutor;
use glam::DVec2;
use graph_craft::ProtoNodeIdentifier;
@@ -28,10 +27,11 @@ use serde_json::Value;
use std::collections::{HashMap, VecDeque};
pub struct NodePropertiesContext<'a> {
pub cached_data: &'a CachedData,
pub responses: &'a mut VecDeque<Message>,
pub executor: &'a mut NodeGraphExecutor,
pub network_interface: &'a mut NodeNetworkInterface,
pub resources: &'a ResourceMessageHandler,
pub fonts: &'a FontsMessageHandler,
pub selection_network_path: &'a [NodeId],
pub document_name: &'a str,
}
@@ -6,12 +6,13 @@ use crate::messages::layout::utility_types::widget_prelude::*;
use crate::messages::portfolio::document::node_graph::document_node_definitions::resolve_document_node_type;
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use crate::messages::portfolio::document::utility_types::network_interface::{InputConnector, NodeNetworkInterface};
use crate::messages::portfolio::utility_types::{CachedData, FontCatalogStyle};
use crate::messages::portfolio::fonts::utility_types::FontCatalogStyle;
use crate::messages::prelude::*;
use crate::messages::tool::common_functionality::graph_modification_utils;
use choice::enum_choice;
use dyn_any::DynAny;
use glam::{DAffine2, DVec2};
use graph_craft::application_io::resource::ResourceId;
use graph_craft::document::value::TaggedValue;
use graph_craft::document::{DocumentNode, DocumentNodeImplementation, NodeId, NodeInput};
use graph_craft::{Type, concrete};
@@ -822,11 +823,27 @@ pub fn array_of_number_widget(parameter_widgets_info: ParameterWidgetsInfo, text
}
pub fn font_inputs(parameter_widgets_info: ParameterWidgetsInfo) -> (Vec<WidgetInstance>, Option<Vec<WidgetInstance>>) {
pub fn assign_font_message(node_id: NodeId, font: Font) -> Message {
let resource_id = ResourceId::new();
Message::Batched {
messages: Box::new([
DocumentMessage::Resource(ResourceMessage::AddFont { resource_id, font }).into(),
NodeGraphMessage::SetInputValue {
node_id,
input_index: graphene_std::text::text::FontInput::INDEX,
value: TaggedValue::Resource(resource_id),
}
.into(),
]),
}
}
let ParameterWidgetsInfo {
cached_data,
document_node,
node_id,
index,
resources,
fonts,
..
} = parameter_widgets_info;
@@ -839,66 +856,32 @@ pub fn font_inputs(parameter_widgets_info: ParameterWidgetsInfo) -> (Vec<WidgetI
return (vec![], None);
};
if let Some(TaggedValue::Font(font)) = &input.as_non_exposed_value() {
if let Some(TaggedValue::Resource(resource_id)) = input.as_non_exposed_value() {
let font = fonts.id_font(resources, *resource_id).unwrap_or_default();
first_widgets.extend_from_slice(&[
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
DropdownInput::new(vec![
cached_data
fonts
.font_catalog
.0
.iter()
.map(|family| {
let FontCatalogStyle { weight, italic, .. } = FontCatalogStyle::from_named_style(&font.font_style, "");
let new_font = Font::new(family.name.clone(), family.closest_style(weight, italic).to_named_style());
let commit_font = new_font.clone();
MenuListEntry::new(family.name.clone())
.label(family.name.clone())
.font(family.closest_style(400, false).preview_url(&family.name))
.on_update({
// Construct the new font using the new family and the initial or previous style, although this style might not exist in the catalog
let mut new_font = Font::new(family.name.clone(), font.font_style_to_restore.clone().unwrap_or_else(|| font.font_style.clone()));
new_font.font_style_to_restore = font.font_style_to_restore.clone();
// If not already, store the initial style so it can be restored if the user switches to another family
if new_font.font_style_to_restore.is_none() {
new_font.font_style_to_restore = Some(new_font.font_style.clone());
}
// Use the closest style available in the family for the new font to ensure the style exists
let FontCatalogStyle { weight, italic, .. } = FontCatalogStyle::from_named_style(&new_font.font_style, "");
new_font.font_style = family.closest_style(weight, italic).to_named_style();
move |_| {
let new_font = new_font.clone();
Message::Batched {
messages: Box::new([
PortfolioMessage::LoadFontData { font: new_font.clone() }.into(),
update_value(move |_| TaggedValue::Font(new_font.clone()), node_id, index)(&()),
]),
}
}
})
.on_commit({
// Use the new value from the user selection
let font_family = family.name.clone();
// Use the previous style selection and extract its weight and italic properties, then find the closest style in the new family
let FontCatalogStyle { weight, italic, .. } = FontCatalogStyle::from_named_style(&font.font_style, "");
let font_style = family.closest_style(weight, italic).to_named_style();
move |_| {
// Intentionally drop `font_style_to_restore` on commit so the committed style becomes the new basis
// for subsequent family switches. Preserving the original style intent is hover-only behavior.
let new_font = Font::new(font_family.clone(), font_style.clone());
DeferMessage::AfterGraphRun {
messages: vec![update_value(move |_| TaggedValue::Font(new_font.clone()), node_id, index)(&()), commit_value(&())],
}
.into()
.on_update(move |_| assign_font_message(node_id, new_font.clone()))
.on_commit(move |_| {
DeferMessage::AfterGraphRun {
messages: vec![assign_font_message(node_id, commit_font.clone()), commit_value(&())],
}
.into()
})
})
.collect::<Vec<_>>(),
])
.selected_index(cached_data.font_catalog.0.iter().position(|family| family.name == font.font_family).map(|i| i as u32))
.selected_index(fonts.font_catalog.iter().position(|family| family.name == font.font_family).map(|i| i as u32))
.virtual_scrolling(true)
.widget_instance(),
]);
@@ -908,32 +891,18 @@ pub fn font_inputs(parameter_widgets_info: ParameterWidgetsInfo) -> (Vec<WidgetI
second_row.extend_from_slice(&[
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
DropdownInput::new({
cached_data
fonts
.font_catalog
.0
.iter()
.find(|family| family.name == font.font_family)
.map(|family| {
let build_entry = |style: &FontCatalogStyle| {
let font_style = style.to_named_style();
let font_family = font.font_family.clone();
let new_font = Font::new(font_family, font_style.clone());
MenuListEntry::new(font_style.clone())
.label(font_style.clone())
.on_update({
let font_family = font.font_family.clone();
let font_style = font_style.clone();
move |_| {
// Keep the existing family
let new_font = Font::new(font_family.clone(), font_style.clone());
Message::Batched {
messages: Box::new([
PortfolioMessage::LoadFontData { font: new_font.clone() }.into(),
update_value(move |_| TaggedValue::Font(new_font.clone()), node_id, index)(&()),
]),
}
}
})
.label(font_style)
.on_update(move |_| assign_font_message(node_id, new_font.clone()))
.on_commit(commit_value)
};
@@ -946,9 +915,8 @@ pub fn font_inputs(parameter_widgets_info: ParameterWidgetsInfo) -> (Vec<WidgetI
.unwrap_or_default()
})
.selected_index(
cached_data
fonts
.font_catalog
.0
.iter()
.find(|family| family.name == font.font_family)
.and_then(|family| {
@@ -2813,8 +2781,8 @@ pub fn math_properties(node_id: NodeId, context: &mut NodePropertiesContext) ->
}
pub struct ParameterWidgetsInfo<'a> {
cached_data: &'a CachedData,
network_interface: &'a NodeNetworkInterface,
resources: &'a ResourceMessageHandler,
selection_network_path: &'a [NodeId],
document_node: Option<&'a DocumentNode>,
node_id: NodeId,
@@ -2824,6 +2792,7 @@ pub struct ParameterWidgetsInfo<'a> {
input_type: FrontendGraphDataType,
blank_assist: bool,
exposable: bool,
fonts: &'a FontsMessageHandler,
}
impl<'a> ParameterWidgetsInfo<'a> {
@@ -2836,9 +2805,10 @@ impl<'a> ParameterWidgetsInfo<'a> {
let document_node = context.network_interface.document_node(&node_id, context.selection_network_path);
ParameterWidgetsInfo {
cached_data: context.cached_data,
network_interface: context.network_interface,
resources: context.resources,
selection_network_path: context.selection_network_path,
fonts: context.fonts,
document_node,
node_id,
index,
@@ -2,11 +2,12 @@ use super::utility_types::{DrawHandles, OverlayContext};
use crate::consts::HIDE_HANDLE_DISTANCE;
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface;
use crate::messages::portfolio::fonts::FALLBACK_FONT_RESOURCE;
use crate::messages::tool::common_functionality::shape_editor::{SelectedLayerState, ShapeState};
use crate::messages::tool::tool_messages::tool_prelude::DocumentMessageHandler;
use glam::{DAffine2, DVec2};
use graphene_std::subpath::{Bezier, BezierHandles};
use graphene_std::text::{Font, FontCache, TextAlign, TextContext, TypesettingConfig};
use graphene_std::text::{TextAlign, TextContext, TypesettingConfig};
use graphene_std::vector::misc::ManipulatorPointId;
use graphene_std::vector::{PointId, SegmentId, Vector};
use std::collections::HashMap;
@@ -221,16 +222,6 @@ pub fn path_endpoint_overlays(document: &DocumentMessageHandler, shape_editor: &
}
}
// Global lazy initialized font cache and text context
pub static GLOBAL_FONT_CACHE: LazyLock<FontCache> = LazyLock::new(|| {
let mut font_cache = FontCache::default();
// Initialize with the hardcoded font used by overlay text
const FONT_DATA: &[u8] = include_bytes!("source-sans-pro-regular.ttf");
let font = Font::new("Source Sans Pro".to_string(), "Regular".to_string());
font_cache.insert(font, FONT_DATA.to_vec());
font_cache
});
pub static GLOBAL_TEXT_CONTEXT: LazyLock<Mutex<TextContext>> = LazyLock::new(|| Mutex::new(TextContext::default()));
pub fn text_width(text: &str, font_size: f64) -> f64 {
@@ -244,12 +235,8 @@ pub fn text_width(text: &str, font_size: f64) -> f64 {
align: TextAlign::AlignLeft,
};
// Load Source Sans Pro font data
// TODO: Grab this from the node_modules folder (either with `include_bytes!` or ideally at runtime) instead of checking the font file into the repo.
// TODO: And maybe use the WOFF2 version (if it's supported) for its smaller, compressed file size.
let font = Font::new("Source Sans Pro".to_string(), "Regular".to_string());
let mut text_context = GLOBAL_TEXT_CONTEXT.lock().expect("Failed to lock global text context");
let bounds = text_context.bounding_box(text, &font, &GLOBAL_FONT_CACHE, typesetting, false);
let bounds = text_context.bounding_box(text, &FALLBACK_FONT_RESOURCE, typesetting, false);
bounds.x
}
@@ -3,8 +3,9 @@ use crate::consts::{
COLOR_OVERLAY_YELLOW_DULL, COMPASS_ROSE_ARROW_SIZE, COMPASS_ROSE_HOVER_RING_DIAMETER, COMPASS_ROSE_MAIN_RING_DIAMETER, COMPASS_ROSE_RING_INNER_DIAMETER, DOWEL_PIN_RADIUS,
GRADIENT_MIDPOINT_DIAMOND_RADIUS, MANIPULATOR_GROUP_MARKER_SIZE, PIVOT_CROSSHAIR_LENGTH, PIVOT_CROSSHAIR_THICKNESS, PIVOT_DIAMETER, RESIZE_HANDLE_SIZE, SKEW_TRIANGLE_OFFSET, SKEW_TRIANGLE_SIZE,
};
use crate::messages::portfolio::document::overlays::utility_functions::{GLOBAL_FONT_CACHE, GLOBAL_TEXT_CONTEXT, hex_to_rgba_u8};
use crate::messages::portfolio::document::overlays::utility_functions::{GLOBAL_TEXT_CONTEXT, hex_to_rgba_u8};
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use crate::messages::portfolio::fonts::FALLBACK_FONT_RESOURCE;
use crate::messages::prelude::Message;
use crate::messages::prelude::ViewportMessageHandler;
use core::borrow::Borrow;
@@ -14,7 +15,7 @@ use graphene_std::ATTR_TRANSFORM;
use graphene_std::list::List;
use graphene_std::math::quad::Quad;
use graphene_std::subpath::{self, Subpath};
use graphene_std::text::{Font, TextAlign, TypesettingConfig};
use graphene_std::text::{TextAlign, TypesettingConfig};
use graphene_std::vector::click_target::ClickTargetType;
use graphene_std::vector::misc::point_to_dvec2;
use graphene_std::vector::{PointId, SegmentId, Vector};
@@ -1115,21 +1116,16 @@ impl OverlayContextInternal {
align: TextAlign::AlignLeft,
};
// Load Source Sans Pro font data
// TODO: Grab this from the node_modules folder (either with `include_bytes!` or ideally at runtime) instead of checking the font file into the repo.
// TODO: And maybe use the WOFF2 version (if it's supported) for its smaller, compressed file size.
let font = Font::new("Source Sans Pro".to_string(), "Regular".to_string());
// Get text dimensions directly from layout
let mut text_context = GLOBAL_TEXT_CONTEXT.lock().expect("Failed to lock global text context");
let text_size = text_context.bounding_box(text, &font, &GLOBAL_FONT_CACHE, typesetting, false);
let text_size = text_context.bounding_box(text, &FALLBACK_FONT_RESOURCE, typesetting, false);
let text_width = text_size.x;
let text_height = text_size.y;
// Create a rect from the size (assuming text starts at origin)
let text_bounds = kurbo::Rect::new(0., 0., text_width, text_height);
// Convert text to vector paths for rendering
let text_list = text_context.to_path(text, &font, &GLOBAL_FONT_CACHE, typesetting, false);
let text_list = text_context.to_path(text, &FALLBACK_FONT_RESOURCE, typesetting, false);
// Calculate position based on pivot
let mut position = DVec2::ZERO;
@@ -3,17 +3,17 @@ use graphene_std::uuid::NodeId;
use crate::messages::layout::utility_types::widget_prelude::*;
use crate::messages::portfolio::document::node_graph::document_node_definitions::NodePropertiesContext;
use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface;
use crate::messages::portfolio::utility_types::CachedData;
use crate::messages::prelude::*;
use crate::node_graph_executor::NodeGraphExecutor;
#[derive(ExtractField)]
pub struct PropertiesPanelMessageContext<'a> {
pub executor: &'a mut NodeGraphExecutor,
pub network_interface: &'a mut NodeNetworkInterface,
pub resources: &'a ResourceMessageHandler,
pub selection_network_path: &'a [NodeId],
pub document_name: &'a str,
pub executor: &'a mut NodeGraphExecutor,
pub cached_data: &'a CachedData,
pub fonts: &'a FontsMessageHandler,
pub properties_panel_open: bool,
}
@@ -24,11 +24,12 @@ pub struct PropertiesPanelMessageHandler {}
impl MessageHandler<PropertiesPanelMessage, PropertiesPanelMessageContext<'_>> for PropertiesPanelMessageHandler {
fn process_message(&mut self, message: PropertiesPanelMessage, responses: &mut VecDeque<Message>, context: PropertiesPanelMessageContext) {
let PropertiesPanelMessageContext {
executor,
network_interface,
resources,
selection_network_path,
document_name,
executor,
cached_data,
fonts,
properties_panel_open,
} = context;
@@ -46,12 +47,13 @@ impl MessageHandler<PropertiesPanelMessage, PropertiesPanelMessageContext<'_>> f
}
let mut node_properties_context = NodePropertiesContext {
cached_data,
responses,
executor,
network_interface,
resources,
selection_network_path,
document_name,
executor,
fonts,
};
let layout = Layout(NodeGraphMessageHandler::collate_properties(&mut node_properties_context));
@@ -1,9 +1,14 @@
use crate::messages::prelude::*;
use graph_craft::application_io::resource::ResourceId;
use graphene_std::text::Font;
use std::sync::Arc;
#[impl_message(Message, DocumentMessage, Resource)]
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum ResourceMessage {
StoreEmbedded { resource_id: ResourceId, data: Arc<[u8]> },
AddFont { resource_id: ResourceId, font: Font },
Resolve,
ResolveStep { resource_id: ResourceId },
Resolved { resource_id: ResourceId, data: Arc<[u8]> },
}
@@ -3,25 +3,149 @@ use crate::messages::prelude::*;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use graph_craft::application_io::resource::{DataSource, LoadResource, Resource, ResourceHash, ResourceId, ResourceRegistry};
use graphene_std::text::Font;
#[derive(ExtractField)]
pub struct ResourceMessageContext {}
pub struct ResourceMessageContext<'a> {
pub document_id: DocumentId,
pub fonts: &'a FontsMessageHandler,
}
#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, ExtractField)]
pub struct ResourceMessageHandler {
pub registry: ResourceRegistry,
pub embedded: EmbeddedResources,
#[serde(skip)]
pending_resolves: HashMap<ResourceId, Option<ResolveProgress>>,
}
#[derive(Debug, Clone, PartialEq)]
struct ResolveProgress {
index: usize,
source: DataSource,
}
#[message_handler_data]
impl MessageHandler<ResourceMessage, ResourceMessageContext> for ResourceMessageHandler {
fn process_message(&mut self, message: ResourceMessage, responses: &mut VecDeque<Message>, _context: ResourceMessageContext) {
impl MessageHandler<ResourceMessage, ResourceMessageContext<'_>> for ResourceMessageHandler {
fn process_message(&mut self, message: ResourceMessage, responses: &mut VecDeque<Message>, context: ResourceMessageContext) {
let ResourceMessageContext { document_id, fonts } = context;
match message {
ResourceMessage::StoreEmbedded { resource_id, data } => {
let hash = ResourceHash::from(data.as_ref());
self.registry.push_source_back(&resource_id, DataSource::Embedded);
self.registry.resolve(&resource_id, hash);
responses.add(ResourceStorageMessage::Store { data });
responses.add(ResourceMessage::Resolve);
}
ResourceMessage::AddFont { resource_id, font } => {
let style = fonts.font_catalog.find_font_style_in_catalog(&font);
let style_name = style.map(|style| style.to_named_style()).unwrap_or_else(|| font.font_style.clone());
self.registry.push_source_back(&resource_id, DataSource::Embedded);
self.registry.push_source_back(
&resource_id,
DataSource::Font {
family: font.font_family,
style: Some(style_name),
},
);
responses.add(ResourceMessage::Resolve);
}
ResourceMessage::Resolve => {
let unresolved_ids: Vec<ResourceId> = self.registry.unresolved().map(|info| info.id).collect();
for id in unresolved_ids {
if self.pending_resolves.contains_key(&id) {
continue;
}
self.pending_resolves.insert(id, None);
responses.add(ResourceMessage::ResolveStep { resource_id: id });
}
}
ResourceMessage::ResolveStep { resource_id } => {
let Some(progress) = self.pending_resolves.get_mut(&resource_id) else { return };
let Some(info) = self.registry.info(&resource_id) else {
log::error!("ResolveStep for {resource_id}: no registry entry");
self.pending_resolves.remove(&resource_id);
return;
};
let index = if let Some(progress) = progress { progress.index + 1 } else { 0 };
let Some(source) = info.sources.get(index).cloned() else {
log::error!("ResolveStep for {resource_id}: no more sources to try");
self.pending_resolves.remove(&resource_id);
return;
};
*progress = Some(ResolveProgress { index, source: source.clone() });
match source {
DataSource::Embedded => {
// Embedded resources are loaded on document load.
// If we get to this point, it means the resource was not embedded and we should try the next source.
responses.add(ResourceMessage::ResolveStep { resource_id });
}
DataSource::Url(url) => {
responses.add(FrontendMessage::TriggerResolveResource {
document_id,
resource_id,
url: url.to_string(),
});
}
DataSource::Font { family, style } => {
let font = match style {
Some(style) => Font::new(family, style),
None => Font::new_with_default_style(family),
};
if let Some(hash) = fonts.cached_hash(&font) {
self.registry.resolve(&resource_id, hash);
self.pending_resolves.remove(&resource_id);
responses.add(NodeGraphMessage::RunDocumentGraph);
return;
}
if let Some(url) = fonts.cached_url(&font) {
responses.add(FrontendMessage::TriggerResolveResource { document_id, resource_id, url });
return;
}
responses.add(FrontendMessage::TriggerFontCatalogLoad);
self.pending_resolves.remove(&resource_id);
}
}
}
ResourceMessage::Resolved { resource_id, data } => {
let hash = ResourceHash::from(data.as_ref());
let Some(progress) = self.pending_resolves.remove(&resource_id).and_then(|p| p) else {
log::error!("Resolved message for {resource_id} with no pending resolve");
return;
};
let Some(info) = self.registry.info(&resource_id) else {
// ResourceId was removed from registry after resolve started.
// This can happen if the document was modified while resolves were in-flight.
// Likely safe to ignore for now.
// TODO: Consider adding cleaner cancelation for in-flight resolves.
return;
};
let Some(source) = info.sources.get(progress.index).cloned() else {
log::error!("Resolved message for {resource_id} with no current source");
return;
};
if progress.source != source {
log::error!("Resolved message for {resource_id} with mismatched source");
return;
}
self.registry.resolve(&resource_id, hash);
responses.add(ResourceStorageMessage::Store { data });
if let DataSource::Font { family, style } = source {
let font = match style {
Some(style) => Font::new(family, style),
None => Font::new_with_default_style(family),
};
responses.add(FontsMessage::ResourceResolved { font, hash });
}
responses.add(ResourceMessage::Resolve);
responses.add(NodeGraphMessage::RunDocumentGraph);
}
}
}
@@ -54,7 +178,7 @@ impl ResourceMessageHandler {
self.embedded = EmbeddedResources::from_iter(futures::future::join_all(embedded).await.into_iter().flatten());
}
pub fn garbage_collect(&mut self, used: &[ResourceId]) {
pub fn collect_garbage(&mut self, used: &[ResourceId]) {
let used = HashSet::<ResourceId>::from_iter(used.iter().cloned());
let unused = self.registry.ids().filter(|id| !used.contains(id)).collect::<Vec<_>>();
unused.into_iter().for_each(|id| {
@@ -1685,6 +1685,28 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
}
}
// Convert text nodes from the old `editor-api` scope + `Font` input to a single font `Resource` input.
// The chosen typeface is recorded as a `DataSource::Font` in the document's resource registry and loaded on open.
if reference == DefinitionIdentifier::ProtoNode(graphene_std::text::text::IDENTIFIER) && inputs_count == 13 && matches!(node.inputs.first(), Some(NodeInput::Scope(_))) {
document
.network_interface
.set_input(&InputConnector::node(*node_id, 0), NodeInput::value(TaggedValue::None, false), network_path);
if let Some(TaggedValue::Font(font)) = node.inputs.get(2).and_then(|input| input.as_value()) {
let resource_id = ResourceId::new();
document.resources.registry.push_source_back(
&resource_id,
DataSource::Font {
family: font.font_family.clone(),
style: Some(font.font_style.clone()),
},
);
document
.network_interface
.set_input(&InputConnector::node(*node_id, 2), NodeInput::value(TaggedValue::Resource(resource_id), false), network_path);
}
}
if reference == DefinitionIdentifier::ProtoNode(graphene_std::raster_nodes::std_nodes::noise_pattern::IDENTIFIER) && inputs_count == 15 {
let mut node_template = resolve_document_node_type(&reference)?.default_node_template();
document.network_interface.replace_implementation(node_id, network_path, &mut node_template);
@@ -0,0 +1,5 @@
use graph_craft::application_io::resource::Resource;
use std::sync::LazyLock;
const FALLBACK_FONT_BYTES: &[u8] = include_bytes!("source-sans-pro-regular.ttf");
pub static FALLBACK_FONT_RESOURCE: LazyLock<Resource> = LazyLock::new(|| Resource::new(FALLBACK_FONT_BYTES));
@@ -0,0 +1,25 @@
use crate::messages::portfolio::fonts::utility_types::FontCatalog;
use crate::messages::prelude::*;
use graph_craft::application_io::resource::{Resource, ResourceHash};
use graphene_std::text::Font;
#[impl_message(Message, PortfolioMessage, Fonts)]
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum FontsMessage {
CatalogLoaded {
catalog: FontCatalog,
},
ResourceResolved {
font: Font,
hash: ResourceHash,
},
Load {
font: Font,
response: Box<Message>,
},
Cached {
hash: ResourceHash,
#[serde(skip, default = "Resource::empty")]
resource: Resource,
},
}
@@ -0,0 +1,112 @@
use crate::messages::portfolio::fonts::FALLBACK_FONT_RESOURCE;
use crate::messages::portfolio::fonts::utility_types::FontCatalog;
use crate::messages::prelude::*;
use graph_craft::application_io::resource::{DataSource, Resource, ResourceHash, ResourceId};
use graphene_std::text::Font;
#[derive(ExtractField)]
pub struct FontsMessageContext<'a> {
pub resource_storage: &'a ResourceStorageMessageHandler,
}
#[derive(Debug, Default, ExtractField)]
pub struct FontsMessageHandler {
pub font_catalog: FontCatalog,
font_hashes: HashMap<Font, ResourceHash>,
font_data: HashMap<ResourceHash, Resource>,
}
#[message_handler_data]
impl MessageHandler<FontsMessage, FontsMessageContext<'_>> for FontsMessageHandler {
fn process_message(&mut self, message: FontsMessage, responses: &mut VecDeque<Message>, context: FontsMessageContext) {
let FontsMessageContext { resource_storage } = context;
match message {
FontsMessage::CatalogLoaded { catalog } => {
self.font_catalog = catalog;
responses.add(PortfolioMessage::ResolveResources);
}
FontsMessage::ResourceResolved { font, hash } => {
let font = self.normalize(font);
self.font_hashes.insert(font, hash);
}
FontsMessage::Load { font, response } => {
let font = self.normalize(font);
let Some(hash) = self.font_hashes.get(&font).copied() else {
log::warn!("FontsMessage::Load for {font:?} with no known hash; ignoring");
return;
};
if self.font_data.contains_key(&hash) {
responses.add(*response);
return;
}
let loader = resource_storage.resources();
responses.add(async move {
let resource = loader.load(hash).await;
match resource {
Some(resource) => Message::Batched {
messages: Box::new([FontsMessage::Cached { hash, resource }.into(), *response]),
},
None => {
log::warn!("Storage missing data for font hash {hash}");
*response
}
}
});
}
FontsMessage::Cached { hash, resource } => {
self.font_data.insert(hash, resource);
}
}
}
advertise_actions!(FontsMessageDiscriminant;);
}
impl FontsMessageHandler {
pub fn cached_hash(&self, font: &Font) -> Option<ResourceHash> {
let font = self.normalize(font.clone());
self.font_hashes.get(&font).copied()
}
pub fn cached_url(&self, font: &Font) -> Option<String> {
let font = self.normalize(font.clone());
self.font_catalog.download_url(&font)
}
pub fn get_resource_or_queue_load(&self, font: &Font, responses: &mut VecDeque<Message>) -> Resource {
let font = self.normalize(font.clone());
if let Some(hash) = self.font_hashes.get(&font) {
if let Some(resource) = self.font_data.get(hash) {
return resource.clone();
}
responses.add(FontsMessage::Load {
font: font.clone(),
response: Message::NoOp.into(),
});
}
FALLBACK_FONT_RESOURCE.clone()
}
pub fn id_font(&self, resources: &ResourceMessageHandler, resource_id: ResourceId) -> Option<Font> {
let info = resources.registry.info(&resource_id)?;
info.sources.iter().find_map(|source| match source {
DataSource::Font { family, style } => Some(match style {
Some(style) => Font::new(family.clone(), style.clone()),
None => Font::new_with_default_style(family.clone()),
}),
_ => None,
})
}
pub fn used_resources(&self) -> impl Iterator<Item = ResourceHash> + '_ {
self.font_hashes.values().copied().chain(self.font_data.keys().copied())
}
fn normalize(&self, font: Font) -> Font {
match self.font_catalog.find_font_style_in_catalog(&font) {
Some(style) => Font::new(font.font_family, style.to_named_style()),
None => font,
}
}
}
@@ -0,0 +1,12 @@
mod fallback;
mod fonts_message;
mod fonts_message_handler;
pub mod utility_types;
#[doc(inline)]
pub use fallback::FALLBACK_FONT_RESOURCE;
#[doc(inline)]
pub use fonts_message::{FontsMessage, FontsMessageDiscriminant};
#[doc(inline)]
pub use fonts_message_handler::{FontsMessageContext, FontsMessageHandler};
@@ -0,0 +1,104 @@
use graphene_std::text::Font;
// TODO: Should this be a BTreeMap instead?
#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct FontCatalog(Vec<FontCatalogFamily>);
impl FontCatalog {
pub fn find_font_style_in_catalog(&self, font: &Font) -> Option<FontCatalogStyle> {
let family = self.0.iter().find(|family| family.name == font.font_family);
let found_style = family.map(|family| {
let FontCatalogStyle { weight, italic, .. } = FontCatalogStyle::from_named_style(&font.font_style, "");
family.closest_style(weight, italic).clone()
});
if found_style.is_none() {
log::warn!("Font not found in catalog: {:?}", font);
}
found_style
}
pub fn download_url(&self, font: &Font) -> Option<String> {
let catalog_family = self.0.iter().find(|catalog_family| catalog_family.name == font.font_family)?;
let FontCatalogStyle { weight, italic, .. } = FontCatalogStyle::from_named_style(&font.font_style, "");
Some(catalog_family.closest_style(weight, italic).url.clone())
}
pub fn iter(&self) -> impl Iterator<Item = &FontCatalogFamily> {
self.0.iter()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
impl From<Vec<FontCatalogFamily>> for FontCatalog {
fn from(value: Vec<FontCatalogFamily>) -> Self {
Self(value)
}
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(from_wasm_abi))]
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct FontCatalogFamily {
/// The font family name.
pub name: String,
/// The font styles (variants) available for the font family.
pub styles: Vec<FontCatalogStyle>,
}
impl FontCatalogFamily {
/// Finds the closest style to the given weight and italic setting.
/// Aims to find the nearest weight while maintaining the italic setting if possible, but italic may change if no other option is available.
pub fn closest_style(&self, weight: u32, italic: bool) -> &FontCatalogStyle {
static FALLBACK_STYLE: FontCatalogStyle = FontCatalogStyle {
weight: 400,
italic: false,
url: String::new(),
};
self.styles
.iter()
.map(|style| ((style.weight as i32 - weight as i32).unsigned_abs() + 10000 * (style.italic != italic) as u32, style))
.min_by_key(|(distance, _)| *distance)
.map(|(_, style)| style)
.unwrap_or(self.styles.first().unwrap_or(&FALLBACK_STYLE))
}
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct FontCatalogStyle {
pub weight: u32,
pub italic: bool,
pub url: String,
}
impl FontCatalogStyle {
pub fn to_named_style(&self) -> String {
let weight = self.weight;
let italic = self.italic;
let named_weight = Font::named_weight(weight);
let maybe_italic = if italic { " Italic" } else { "" };
format!("{named_weight}{maybe_italic} ({weight})")
}
pub fn from_named_style(named_style: &str, url: impl Into<String>) -> FontCatalogStyle {
let weight = named_style.split_terminator(['(', ')']).next_back().and_then(|x| x.parse::<u32>().ok()).unwrap_or(400);
let italic = named_style.contains("Italic (");
FontCatalogStyle { weight, italic, url: url.into() }
}
/// Get the URL for the stylesheet for loading a font preview for this style of the given family name, subsetted to only the letters in the family name.
pub fn preview_url(&self, family: impl Into<String>) -> String {
let name = family.into().replace(' ', "+");
let italic = if self.italic { "ital," } else { "" };
let weight = self.weight;
format!("https://fonts.googleapis.com/css2?display=swap&family={name}:{italic}wght@{weight}&text={name}")
}
}
+3
View File
@@ -3,9 +3,12 @@ mod portfolio_message_handler;
pub mod document;
pub mod document_migration;
pub mod fonts;
pub mod persistent_state;
pub mod utility_types;
#[doc(inline)]
pub use fonts::{FontsMessage, FontsMessageContext, FontsMessageHandler};
#[doc(inline)]
pub use persistent_state::{PersistentStateMessage, PersistentStateMessageContext, PersistentStateMessageHandler};
#[doc(inline)]
@@ -52,3 +52,9 @@ impl MessageHandler<PersistentStateMessage, PersistentStateMessageContext> for P
advertise_actions!(PersistentStateMessageDiscriminant;);
}
impl PersistentStateMessageHandler {
pub fn loaded(&self) -> bool {
self.loaded
}
}
@@ -3,11 +3,9 @@ use super::persistent_state::PersistentStateMessage;
use super::utility_types::{DockingSplitDirection, PanelGroupId, PanelType};
use crate::messages::frontend::utility_types::{ExportBounds, FileType, PersistedState};
use crate::messages::portfolio::document::utility_types::clipboards::Clipboard;
use crate::messages::portfolio::utility_types::FontCatalog;
use crate::messages::prelude::*;
use graphene_std::Color;
use graphene_std::raster::Image;
use graphene_std::text::Font;
use std::path::PathBuf;
#[impl_message(Message, Portfolio)]
@@ -17,6 +15,8 @@ pub enum PortfolioMessage {
#[child]
Document(DocumentMessage),
#[child]
Fonts(FontsMessage),
#[child]
PersistentState(PersistentStateMessage),
// Messages
@@ -51,20 +51,10 @@ pub enum PortfolioMessage {
DestroyAllDocuments,
EditorPreferences,
GarbageCollectResources,
FontCatalogLoaded {
catalog: FontCatalog,
},
LoadFontData {
font: Font,
},
FontLoaded {
font_family: String,
font_style: String,
data: Vec<u8>,
},
LoadDocumentResources {
ResolveDocumentResources {
document_id: DocumentId,
},
ResolveResources,
LoadPersistedState {
state: PersistedState,
},
@@ -1,7 +1,7 @@
use super::document::utility_types::document_metadata::LayerNodeIdentifier;
use super::document::utility_types::network_interface;
use super::persistent_state::{PersistentStateMessage, PersistentStateMessageContext, PersistentStateMessageHandler};
use super::utility_types::{CachedData, PanelLayoutSubdivision, PanelType, WorkspacePanelLayout};
use super::utility_types::{PanelLayoutSubdivision, PanelType, WorkspacePanelLayout};
use crate::application::{Editor, generate_uuid};
use crate::consts::{DEFAULT_DOCUMENT_NAME, DEFAULT_STROKE_WIDTH, FILE_EXTENSION};
use crate::messages::animation::TimingInformation;
@@ -32,7 +32,6 @@ use graphene_std::Color;
use graphene_std::raster_types::Image;
use graphene_std::renderer::Quad;
use graphene_std::subpath::BezierHandles;
use graphene_std::text::Font;
use graphene_std::vector::misc::HandleId;
use graphene_std::vector::{PointId, SegmentId, Vector, VectorModificationType};
use std::path::PathBuf;
@@ -68,7 +67,7 @@ pub struct PortfolioMessageHandler {
document_ids: VecDeque<DocumentId>,
pub(crate) active_document_id: Option<DocumentId>,
persistent_state: PersistentStateMessageHandler,
pub cached_data: CachedData,
pub fonts: FontsMessageHandler,
copy_buffer: [Vec<CopyBufferEntry>; INTERNAL_CLIPBOARD_COUNT as usize],
pub executor: NodeGraphExecutor,
pub selection_mode: SelectionMode,
@@ -99,7 +98,7 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
let document_inputs = DocumentMessageContext {
document_id,
ipp,
cached_data: &self.cached_data,
fonts: &self.fonts,
executor: &mut self.executor,
current_tool,
preferences,
@@ -118,6 +117,10 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
};
self.persistent_state.process_message(message, responses, context);
}
PortfolioMessage::Fonts(message) => {
let context = FontsMessageContext { resource_storage };
self.fonts.process_message(message, responses, context);
}
// Messages
PortfolioMessage::Init => {
@@ -169,7 +172,7 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
let document_inputs = DocumentMessageContext {
document_id,
ipp,
cached_data: &self.cached_data,
fonts: &self.fonts,
executor: &mut self.executor,
current_tool,
preferences,
@@ -208,6 +211,7 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
document_id,
document: document.serialize_document(),
});
responses.add(PersistentStateMessage::WriteState);
}
PortfolioMessage::CloseActiveDocumentWithConfirmation => {
if let Some(document_id) = self.active_document_id {
@@ -386,78 +390,16 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
responses.add(MenuBarMessage::SendLayout);
responses.add(PersistentStateMessage::WriteState);
}
PortfolioMessage::FontCatalogLoaded { catalog } => {
self.cached_data.font_catalog = catalog;
if let Some(document_id) = self.active_document_id {
responses.add(PortfolioMessage::LoadDocumentResources { document_id });
}
// Load the default font
let font = Font::new(graphene_std::consts::DEFAULT_FONT_FAMILY.into(), graphene_std::consts::DEFAULT_FONT_STYLE.into());
responses.add(PortfolioMessage::LoadFontData { font });
}
PortfolioMessage::LoadFontData { font } => {
if let Some(style) = self.cached_data.font_catalog.find_font_style_in_catalog(&font) {
let font = Font::new(font.font_family, style.to_named_style());
if !self.cached_data.font_cache.loaded_font(&font) {
responses.add(FrontendMessage::TriggerFontDataLoad { font, url: style.url });
}
}
}
PortfolioMessage::FontLoaded { font_family, font_style, data } => {
let font = Font::new(font_family, font_style);
self.cached_data.font_cache.insert(font, data);
self.executor.update_font_cache(self.cached_data.font_cache.clone());
for document_id in self.document_ids.iter() {
let node_to_inspect = self.node_to_inspect();
let Some(document) = self.documents.get_mut(document_id) else {
if self.unloaded_documents.contains_key(document_id) {
continue;
}
log::error!("Tried to render non-existent document");
continue;
};
let document_to_viewport = document
.navigation_handler
.calculate_offset_transform(viewport.center_in_viewport_space().into(), &document.document_ptz);
let pointer_position = document_to_viewport.inverse().transform_point2(ipp.mouse.position);
let scale = viewport.scale();
// Use exact physical dimensions from browser (via ResizeObserver's devicePixelContentBoxSize)
let physical_resolution = viewport.size().to_physical().into_dvec2().round().as_uvec2();
// TODO: Remove this when we do the SVG rendering with a separate library on desktop, thus avoiding a need for the hole punch.
// TODO: See #3796. There is a second instance of this todo comment and code block (be sure to remove both).
#[cfg(not(target_family = "wasm"))]
responses.add_front(FrontendMessage::UpdateViewportHolePunch {
active: document.render_mode != graphene_std::vector::style::RenderMode::SvgPreview,
});
if let Ok(message) = self
.executor
.submit_node_graph_evaluation(document, *document_id, physical_resolution, scale, timing_information, node_to_inspect, true, pointer_position)
{
responses.add_front(message);
}
}
if self.active_document_mut().is_some() {
responses.add(NodeGraphMessage::RunDocumentGraph);
}
if current_tool == &ToolType::Text {
responses.add(TextToolMessage::RefreshEditingFontData);
}
}
PortfolioMessage::EditorPreferences => self.executor.update_editor_preferences(preferences.editor_preferences()),
PortfolioMessage::GarbageCollectResources => {
if !self.persistent_state.loaded() {
// We don't know what can be safely garbage collected
return;
}
let mut used_resources = HashSet::new();
for (id, info) in self.unloaded_documents.iter() {
log::info!("Checking resources for unloaded document {:?}: {:?}", info.name, info.resources);
if let Some(resources) = &info.resources {
used_resources.extend(resources.iter());
} else {
@@ -469,21 +411,26 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
document.garbage_collect_resources();
used_resources.extend(document.resources.registry.resolved().filter_map(|info| info.hash.cloned()));
}
used_resources.extend(self.fonts.used_resources());
responses.add(ResourceStorageMessage::GarbageCollect {
used: Vec::from_iter(used_resources).into_boxed_slice(),
});
}
PortfolioMessage::LoadDocumentResources { document_id } => {
let catalog = &self.cached_data.font_catalog;
if catalog.0.is_empty() {
PortfolioMessage::ResolveResources => {
for document_id in self.document_ids.iter().copied().collect::<Vec<_>>() {
responses.add(PortfolioMessage::ResolveDocumentResources { document_id });
}
}
PortfolioMessage::ResolveDocumentResources { document_id } => {
if self.fonts.font_catalog.is_empty() {
responses.add_front(FrontendMessage::TriggerFontCatalogLoad);
return;
}
if let Some(document) = self.documents.get_mut(&document_id) {
document.load_layer_resources(responses);
}
responses.add(PortfolioMessage::DocumentPassMessage {
document_id,
message: DocumentMessage::Resource(ResourceMessage::Resolve),
});
}
PortfolioMessage::LoadPersistedState { state } => {
if let Some(layout) = state.workspace_layout {
@@ -884,6 +831,7 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
let mut document = match document {
Ok(document) => document,
Err(e) => {
log::error!("{e}");
// TODO: Eventually remove this document upgrade code
// TODO: (Only the `if` branch, the `else` branch's manual-open dialog stays)
if document_is_auto_saved {
@@ -908,7 +856,6 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
responses.add(PortfolioMessage::UpdateOpenDocumentsList);
self.tick_autosave_load_progress(responses, true);
} else {
log::error!("{e}");
let name = document_name
.filter(|n| !n.trim().is_empty())
.or_else(|| document_path.as_ref().and_then(|p| p.file_stem()).map(|s| s.to_string_lossy().into_owned()))
@@ -1134,7 +1081,6 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
added_nodes = true;
}
document.load_layer_resources(responses);
let new_ids: HashMap<_, _> = entry.nodes.iter().map(|(id, _)| (*id, NodeId::new())).collect();
let layer = LayerNodeIdentifier::new_unchecked(new_ids[&NodeId(0)]);
all_new_ids.extend(new_ids.values().cloned());
@@ -1619,7 +1565,7 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
};
if !document.is_loaded {
document.is_loaded = true;
responses.add(PortfolioMessage::LoadDocumentResources { document_id });
responses.add(PortfolioMessage::ResolveDocumentResources { document_id });
responses.add(PortfolioMessage::UpdateDocumentWidgets);
responses.add(PropertiesPanelMessage::Clear);
}
@@ -1665,6 +1611,13 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
return;
};
// Skip rendering while any resource is still unresolved — the preprocessor would otherwise fail with
// `ResourceNotFound`. `ResourceMessage::Resolved` queues `RunDocumentGraph` once each id resolves,
// so the render fires automatically once the registry is complete.
if document.resources.registry.unresolved().next().is_some() {
return;
}
let document_to_viewport = document
.navigation_handler
.calculate_offset_transform(viewport.center_in_viewport_space().into(), &document.document_ptz);
@@ -1,95 +1,11 @@
use graphene_std::Color;
use graphene_std::raster::Image;
use graphene_std::text::{Font, FontCache};
/// Proportional share (0-1) for the document panel's side when splitting adjacent to non-document panels.
const DOCUMENT_PANEL_SHARE: f64 = 0.8;
/// Proportional share for each side when neither (or both) contain the document panel.
const EQUAL_PANEL_SHARE: f64 = 0.5;
#[derive(Debug, Default)]
pub struct CachedData {
pub font_cache: FontCache,
pub font_catalog: FontCatalog,
}
// TODO: Should this be a BTreeMap instead?
#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct FontCatalog(pub Vec<FontCatalogFamily>);
impl FontCatalog {
pub fn find_font_style_in_catalog(&self, font: &Font) -> Option<FontCatalogStyle> {
let family = self.0.iter().find(|family| family.name == font.font_family);
let found_style = family.map(|family| {
let FontCatalogStyle { weight, italic, .. } = FontCatalogStyle::from_named_style(&font.font_style, "");
family.closest_style(weight, italic).clone()
});
if found_style.is_none() {
log::warn!("Font not found in catalog: {:?}", font);
}
found_style
}
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(from_wasm_abi))]
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct FontCatalogFamily {
/// The font family name.
pub name: String,
/// The font styles (variants) available for the font family.
pub styles: Vec<FontCatalogStyle>,
}
impl FontCatalogFamily {
/// Finds the closest style to the given weight and italic setting.
/// Aims to find the nearest weight while maintaining the italic setting if possible, but italic may change if no other option is available.
pub fn closest_style(&self, weight: u32, italic: bool) -> &FontCatalogStyle {
self.styles
.iter()
.map(|style| ((style.weight as i32 - weight as i32).unsigned_abs() + 10000 * (style.italic != italic) as u32, style))
.min_by_key(|(distance, _)| *distance)
.map(|(_, style)| style)
.unwrap_or(&self.styles[0])
}
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct FontCatalogStyle {
pub weight: u32,
pub italic: bool,
pub url: String,
}
impl FontCatalogStyle {
pub fn to_named_style(&self) -> String {
let weight = self.weight;
let italic = self.italic;
let named_weight = Font::named_weight(weight);
let maybe_italic = if italic { " Italic" } else { "" };
format!("{named_weight}{maybe_italic} ({weight})")
}
pub fn from_named_style(named_style: &str, url: impl Into<String>) -> FontCatalogStyle {
let weight = named_style.split_terminator(['(', ')']).next_back().and_then(|x| x.parse::<u32>().ok()).unwrap_or(400);
let italic = named_style.contains("Italic (");
FontCatalogStyle { weight, italic, url: url.into() }
}
/// Get the URL for the stylesheet for loading a font preview for this style of the given family name, subsetted to only the letters in the family name.
pub fn preview_url(&self, family: impl Into<String>) -> String {
let name = family.into().replace(' ', "+");
let italic = if self.italic { "ital," } else { "" };
let weight = self.weight;
format!("https://fonts.googleapis.com/css2?display=swap&family={name}:{italic}wght@{weight}&text={name}")
}
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(from_wasm_abi))]
#[derive(PartialEq, Eq, Hash, Clone, Copy, Debug, serde::Serialize, serde::Deserialize)]
pub enum PanelType {