diff --git a/Cargo.lock b/Cargo.lock index e465c40ff7..f54f9f44d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4539,7 +4539,9 @@ dependencies = [ "kurbo", "log", "num-traits", + "parley", "serde", + "skrifa", "usvg", "vector-types", "vello", diff --git a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs index 36cde235a8..c321e2385f 100644 --- a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs +++ b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs @@ -336,6 +336,7 @@ impl TableItemLayout for Graphic { Self::RasterGPU(list) => list.identifier(), Self::Color(list) => list.identifier(), Self::Gradient(list) => list.identifier(), + Self::Text(list) => list.identifier(), } } // Don't put a breadcrumb for Graphic @@ -350,6 +351,7 @@ impl TableItemLayout for Graphic { Self::RasterGPU(list) => list.layout_with_breadcrumb(data), Self::Color(list) => list.layout_with_breadcrumb(data), Self::Gradient(list) => list.layout_with_breadcrumb(data), + Self::Text(list) => list.layout_with_breadcrumb(data), } } } diff --git a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs index e658f7846a..9ed4afdc6d 100644 --- a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs +++ b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs @@ -427,6 +427,10 @@ pub fn get_text_id(layer: LayerNodeIdentifier, network_interface: &NodeNetworkIn NodeGraphLayer::new(layer, network_interface).upstream_node_id_from_name(&DefinitionIdentifier::ProtoNode(graphene_std::text::text::IDENTIFIER)) } +pub fn get_text_layer_id(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option { + NodeGraphLayer::new(layer, network_interface).upstream_node_id_from_name(&DefinitionIdentifier::ProtoNode(graphene_std::text::text_layer::IDENTIFIER)) +} + pub fn get_grid_id(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option { NodeGraphLayer::new(layer, network_interface).upstream_node_id_from_name(&DefinitionIdentifier::ProtoNode(graphene_std::vector::generator_nodes::grid::IDENTIFIER)) } @@ -484,6 +488,56 @@ pub fn get_text(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInter Some((text, font, typesetting, per_glyph_items)) } +/// Gets properties from the Text Layer node +pub fn get_text_layer(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option<(&String, &Font, TypesettingConfig)> { + let inputs = NodeGraphLayer::new(layer, network_interface).find_node_inputs(&DefinitionIdentifier::ProtoNode(graphene_std::text::text_layer::IDENTIFIER))?; + + let Some(TaggedValue::String(text)) = &inputs[graphene_std::text::text_layer::TextInput::INDEX].as_value() else { + return None; + }; + let Some(TaggedValue::Font(font)) = &inputs[graphene_std::text::text_layer::FontInput::INDEX].as_value() else { + return None; + }; + let Some(&TaggedValue::F64(font_size)) = inputs[graphene_std::text::text_layer::SizeInput::INDEX].as_value() else { + return None; + }; + let Some(&TaggedValue::F64(line_height_ratio)) = inputs[graphene_std::text::text_layer::LineHeightInput::INDEX].as_value() else { + return None; + }; + let Some(&TaggedValue::F64(character_spacing)) = inputs[graphene_std::text::text_layer::CharacterSpacingInput::INDEX].as_value() else { + return None; + }; + let Some(&TaggedValue::Bool(has_max_width)) = inputs[graphene_std::text::text_layer::HasMaxWidthInput::INDEX].as_value() else { + return None; + }; + let Some(&TaggedValue::F64(max_width)) = inputs[graphene_std::text::text_layer::MaxWidthInput::INDEX].as_value() else { + return None; + }; + let Some(&TaggedValue::Bool(has_max_height)) = inputs[graphene_std::text::text_layer::HasMaxHeightInput::INDEX].as_value() else { + return None; + }; + let Some(&TaggedValue::F64(max_height)) = inputs[graphene_std::text::text_layer::MaxHeightInput::INDEX].as_value() else { + return None; + }; + let Some(&TaggedValue::F64(tilt)) = inputs[graphene_std::text::text_layer::TiltInput::INDEX].as_value() else { + return None; + }; + let Some(&TaggedValue::TextAlign(align)) = inputs[graphene_std::text::text_layer::AlignInput::INDEX].as_value() else { + return None; + }; + + let typesetting = TypesettingConfig { + font_size, + line_height_ratio, + max_width: has_max_width.then_some(max_width), + max_height: has_max_height.then_some(max_height), + character_spacing, + tilt, + align, + }; + Some((text, font, typesetting)) +} + pub fn get_stroke_width(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option { let weight_node_input_index = graphene_std::vector::stroke::WeightInput::INDEX; if let TaggedValue::F64(width) = NodeGraphLayer::new(layer, network_interface).find_input(&DefinitionIdentifier::ProtoNode(graphene_std::vector::stroke::IDENTIFIER), weight_node_input_index)? { diff --git a/editor/src/node_graph_executor/runtime.rs b/editor/src/node_graph_executor/runtime.rs index 660fa6d2a4..1588b52899 100644 --- a/editor/src/node_graph_executor/runtime.rs +++ b/editor/src/node_graph_executor/runtime.rs @@ -15,7 +15,7 @@ use graphene_std::ops::Convert; #[cfg(all(target_family = "wasm", feature = "gpu", feature = "wasm"))] use graphene_std::platform_application_io::canvas_utils::{Canvas, CanvasSurface, CanvasSurfaceHandle}; use graphene_std::raster_types::Raster; -use graphene_std::renderer::{Render, RenderParams, RenderSvgSegmentList, SvgRender, SvgSegment}; +use graphene_std::renderer::{Render, RenderParams, RenderSvgSegmentList, SvgRender, SvgSegment, set_render_fonts}; use graphene_std::text::FontCache; use graphene_std::transform::RenderQuality; use graphene_std::vector::Vector; @@ -200,6 +200,7 @@ impl NodeRuntime { for request in requests { match request { GraphRuntimeRequest::FontCacheUpdate(font_cache) => { + set_render_fonts(font_cache.iter_fonts().map(|(family, style, bytes)| (family.to_string(), style.to_string(), bytes))); self.editor_api = PlatformEditorApi { font_cache, application_io: self.editor_api.application_io.clone(), diff --git a/node-graph/libraries/core-types/src/lib.rs b/node-graph/libraries/core-types/src/lib.rs index b251203ab4..b698170692 100644 --- a/node-graph/libraries/core-types/src/lib.rs +++ b/node-graph/libraries/core-types/src/lib.rs @@ -26,7 +26,8 @@ pub use graphene_hash; pub use graphene_hash::CacheHash; pub use list::{ ATTR_BACKGROUND, ATTR_BLEND_MODE, ATTR_CLIP, ATTR_CLIPPING_MASK, ATTR_DIMENSIONS, ATTR_EDITOR_CLICK_TARGET, ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_EDITOR_TEXT_FRAME, ATTR_END, - ATTR_GRADIENT_TYPE, ATTR_LOCATION, ATTR_NAME, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_SPREAD_METHOD, ATTR_START, ATTR_TRANSFORM, ATTR_TYPE, + ATTR_FONT_FAMILY, ATTR_FONT_SIZE, ATTR_FONT_STYLE, ATTR_GRADIENT_TYPE, ATTR_LOCATION, ATTR_NAME, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_SPREAD_METHOD, ATTR_START, ATTR_TEXT_ALIGN, + ATTR_TEXT_CHARACTER_SPACING, ATTR_TEXT_FONT, ATTR_TEXT_LINE_HEIGHT, ATTR_TEXT_MAX_HEIGHT, ATTR_TEXT_MAX_WIDTH, ATTR_TEXT_TILT, ATTR_TRANSFORM, ATTR_TYPE, }; pub use memo::MemoHash; pub use no_std_types::AsU32; diff --git a/node-graph/libraries/core-types/src/list.rs b/node-graph/libraries/core-types/src/list.rs index 61f54597b4..7d71104b83 100644 --- a/node-graph/libraries/core-types/src/list.rs +++ b/node-graph/libraries/core-types/src/list.rs @@ -77,6 +77,36 @@ pub const ATTR_SPREAD_METHOD: &str = "spread_method"; /// Gradient's `GradientType` (`Linear` or `Radial`). pub const ATTR_GRADIENT_TYPE: &str = "gradient_type"; +/// Text item's font family (`String`, implicit default `"Lato"`). +pub const ATTR_FONT_FAMILY: &str = "font_family"; + +/// Text item's font style (`String`, implicit default `"Regular"`). +pub const ATTR_FONT_STYLE: &str = "font_style"; + +/// Text item's font size in document-space units (`f64`, implicit default `16.`). +pub const ATTR_FONT_SIZE: &str = "font_size"; + +/// Text item's full `Font` struct (family + style). Only set by `text_layer`; used by `text_to_vector` to reconstruct exact glyph paths. +pub const ATTR_TEXT_FONT: &str = "text_font"; + +/// Text item's line height ratio relative to the font size (`f64`, implicit default `1.2`). Only stored when it deviates from the default. +pub const ATTR_TEXT_LINE_HEIGHT: &str = "text_line_height"; + +/// Text item's extra inter-character spacing in document-space units (`f64`, implicit default `0.0`). Only stored when non-zero. +pub const ATTR_TEXT_CHARACTER_SPACING: &str = "text_character_spacing"; + +/// Text item's optional max line-wrap width (`Option`). Absent = no limit; present = wrap at that width. +pub const ATTR_TEXT_MAX_WIDTH: &str = "text_max_width"; + +/// Text item's optional max height cutoff (`Option`). Absent = no limit; lines whose baseline exceeds this value are not drawn. +pub const ATTR_TEXT_MAX_HEIGHT: &str = "text_max_height"; + +/// Text item's faux-italic tilt angle in degrees (`f64`, implicit default `0.0`). Only stored when non-zero. +pub const ATTR_TEXT_TILT: &str = "text_tilt"; + +/// Text item's horizontal alignment encoded as a `u8` discriminant of `TextAlign` (0 = AlignLeft, 1 = AlignCenter, 2 = AlignRight, 3–6 = justify variants). Only stored when non-zero. +pub const ATTR_TEXT_ALIGN: &str = "text_align"; + // ======================== // TRAIT: AnyAttributeValue // ======================== diff --git a/node-graph/libraries/core-types/src/render_complexity.rs b/node-graph/libraries/core-types/src/render_complexity.rs index fc035c720a..15578d771c 100644 --- a/node-graph/libraries/core-types/src/render_complexity.rs +++ b/node-graph/libraries/core-types/src/render_complexity.rs @@ -19,3 +19,9 @@ impl RenderComplexity for Color { 1 } } + +impl RenderComplexity for String { + fn render_complexity(&self) -> usize { + 1 + } +} diff --git a/node-graph/libraries/graphic-types/src/graphic.rs b/node-graph/libraries/graphic-types/src/graphic.rs index 0efade8880..93869ba580 100644 --- a/node-graph/libraries/graphic-types/src/graphic.rs +++ b/node-graph/libraries/graphic-types/src/graphic.rs @@ -22,6 +22,7 @@ pub enum Graphic { RasterGPU(List>), Color(List), Gradient(List), + Text(List), } impl Default for Graphic { @@ -103,6 +104,18 @@ impl From> for Graphic { } } +// String +impl From for Graphic { + fn from(text: String) -> Self { + Graphic::Text(List::new_from_element(text)) + } +} +impl From> for Graphic { + fn from(text: List) -> Self { + Graphic::Text(text) + } +} + /// Deeply flattens a `List`, collecting only elements matching a specific variant (extracted by `extract_variant`) /// and discarding all other non-matching content. Recursion through `Graphic::Graphic` sub-`List`s composes transforms and opacity. fn flatten_graphic_list(content: List, extract_variant: fn(Graphic) -> Option>) -> List { @@ -199,6 +212,12 @@ impl TryFromGraphic for GradientStops { } } +impl TryFromGraphic for String { + fn try_from_graphic(graphic: Graphic) -> Option> { + if let Graphic::Text(t) = graphic { Some(t) } else { None } + } +} + // Local trait to convert types to List (avoids orphan rule issues) pub trait IntoGraphicList { fn into_graphic_list(self) -> List; @@ -255,6 +274,17 @@ impl IntoGraphicList for List { } } +impl IntoGraphicList for List { + fn into_graphic_list(self) -> List { + let layer_path: List = self.attribute_cloned_or_default(ATTR_EDITOR_LAYER_PATH, 0); + let mut graphic_list = List::new_from_element(Graphic::Text(self)); + if !layer_path.is_empty() { + graphic_list.set_attribute(ATTR_EDITOR_LAYER_PATH, 0, layer_path); + } + graphic_list + } +} + impl IntoGraphicList for DAffine2 { fn into_graphic_list(self) -> List { List::new_from_element(Graphic::default()) @@ -324,6 +354,7 @@ impl Graphic { Graphic::RasterGPU(list) => all_clipped(list), Graphic::Color(list) => all_clipped(list), Graphic::Gradient(list) => all_clipped(list), + Graphic::Text(list) => all_clipped(list), } } @@ -348,6 +379,7 @@ impl BoundingBox for Graphic { Graphic::Graphic(list) => list.bounding_box(transform, include_stroke), Graphic::Color(list) => list.bounding_box(transform, include_stroke), Graphic::Gradient(list) => list.bounding_box(transform, include_stroke), + Graphic::Text(_) => RenderBoundingBox::Infinite, } } @@ -359,6 +391,7 @@ impl BoundingBox for Graphic { Graphic::Graphic(graphic) => graphic.thumbnail_bounding_box(transform, include_stroke), Graphic::Color(color) => color.thumbnail_bounding_box(transform, include_stroke), Graphic::Gradient(gradient) => gradient.thumbnail_bounding_box(transform, include_stroke), + Graphic::Text(_) => RenderBoundingBox::Infinite, } } } @@ -388,6 +421,7 @@ impl RenderComplexity for Graphic { Self::RasterGPU(list) => list.render_complexity(), Self::Color(list) => list.render_complexity(), Self::Gradient(list) => list.render_complexity(), + Self::Text(list) => list.len(), } } } diff --git a/node-graph/libraries/rendering/Cargo.toml b/node-graph/libraries/rendering/Cargo.toml index 1fdfb0c839..c8e4375e3c 100644 --- a/node-graph/libraries/rendering/Cargo.toml +++ b/node-graph/libraries/rendering/Cargo.toml @@ -27,6 +27,8 @@ vector-types = { workspace = true } graphic-types = { workspace = true } vello = { workspace = true } vello_encoding = { workspace = true } +parley = { workspace = true } +skrifa = { workspace = true } # Optional workspace dependencies serde = { workspace = true, optional = true } diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 6a6ca7c82b..9c6324c72c 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -13,7 +13,8 @@ use core_types::transform::Footprint; use core_types::uuid::{NodeId, generate_uuid}; use core_types::{ ATTR_BACKGROUND, ATTR_BLEND_MODE, ATTR_CLIP, ATTR_CLIPPING_MASK, ATTR_DIMENSIONS, ATTR_EDITOR_CLICK_TARGET, ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_EDITOR_TEXT_FRAME, - ATTR_GRADIENT_TYPE, ATTR_LOCATION, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_SPREAD_METHOD, ATTR_TRANSFORM, + ATTR_FONT_FAMILY, ATTR_FONT_SIZE, ATTR_FONT_STYLE, ATTR_GRADIENT_TYPE, ATTR_LOCATION, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_SPREAD_METHOD, ATTR_TEXT_ALIGN, ATTR_TEXT_CHARACTER_SPACING, + ATTR_TEXT_LINE_HEIGHT, ATTR_TEXT_MAX_HEIGHT, ATTR_TEXT_MAX_WIDTH, ATTR_TEXT_TILT, ATTR_TRANSFORM, }; use dyn_any::DynAny; use glam::{DAffine2, DVec2}; @@ -24,15 +25,58 @@ use graphic_types::vector_types::subpath::Subpath; use graphic_types::vector_types::vector::click_target::{ClickTarget, FreePoint}; use graphic_types::vector_types::vector::style::{Fill, PaintOrder, RenderMode, Stroke, StrokeAlign}; use graphic_types::{Artboard, Graphic, Vector}; -use kurbo::{Affine, Cap, Join, Shape}; +use kurbo::{Affine, BezPath, Cap, Join, Shape}; use num_traits::Zero; +use parley::{AlignmentOptions, FontContext, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty}; +use skrifa::GlyphId; +use skrifa::MetadataProvider; +use skrifa::instance::{LocationRef, NormalizedCoord, Size}; +use skrifa::outline::{DrawSettings, OutlinePen}; +use skrifa::raw::FontRef as SkrifaFontRef; +use std::borrow::Cow; +use std::cell::RefCell; use std::collections::{HashMap, HashSet}; use std::fmt::Write; +use std::hash::{Hash, Hasher}; use std::ops::Deref; use std::sync::{Arc, LazyLock}; use vector_types::gradient::GradientSpreadMethod; use vello::*; +// Thread local storage for font bytes +thread_local! { + static RENDER_FONTS: RefCell)]>> = RefCell::new(Arc::from([])); +} + +// Thread-local parley font shaping context +thread_local! { + static FONT_CTX: RefCell<(FontContext, LayoutContext<()>)> = RefCell::new((FontContext::default(), LayoutContext::default())); +} + +// Tracks which font bytes have already been registered into FONT_CTX +thread_local! { + static REGISTERED_FONTS: RefCell> = RefCell::new(HashSet::new()); +} + +// Caches the first FontInfo (weight/style/width) for each (family, style) pair after registration +thread_local! { + static FONT_INFO_CACHE: RefCell> = RefCell::new(HashMap::new()); +} + +// Set the font bytes available to the renderer for the current execution. +pub fn set_render_fonts(fonts: impl IntoIterator)>) { + let slice: Arc<[(String, String, u64, Arc<[u8]>)]> = fonts + .into_iter() + .map(|(family, style, bytes)| { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + bytes.hash(&mut hasher); + (family, style, hasher.finish(), bytes) + }) + .collect::>() + .into(); + RENDER_FONTS.with(|f| *f.borrow_mut() = slice); +} + #[derive(Clone, Copy, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] enum MaskType { @@ -229,8 +273,10 @@ impl RenderParams { } pub fn for_alignment(&self, transform: DAffine2) -> Self { - let alignment_parent_transform = Some(transform); - Self { alignment_parent_transform, ..*self } + Self { + alignment_parent_transform: Some(transform), + ..*self + } } pub fn to_canvas(&self) -> bool { @@ -431,6 +477,7 @@ impl Render for Graphic { Graphic::RasterGPU(_) => (), Graphic::Color(list) => list.render_svg(render, render_params), Graphic::Gradient(list) => list.render_svg(render, render_params), + Graphic::Text(list) => list.render_svg(render, render_params), } } @@ -442,6 +489,7 @@ impl Render for Graphic { Graphic::RasterGPU(list) => list.render_to_vello(scene, transform, context, render_params), Graphic::Color(list) => list.render_to_vello(scene, transform, context, render_params), Graphic::Gradient(list) => list.render_to_vello(scene, transform, context, render_params), + Graphic::Text(list) => list.render_to_vello(scene, transform, context, render_params), } } @@ -490,6 +538,14 @@ impl Render for Graphic { Graphic::Gradient(list) => { metadata.upstream_footprints.insert(element_id, footprint); + // TODO: Find a way to handle more than the first item + if !list.is_empty() { + metadata.local_transforms.insert(element_id, list.attribute_cloned_or_default(ATTR_TRANSFORM, 0)); + } + } + Graphic::Text(list) => { + metadata.upstream_footprints.insert(element_id, footprint); + // TODO: Find a way to handle more than the first item if !list.is_empty() { metadata.local_transforms.insert(element_id, list.attribute_cloned_or_default(ATTR_TRANSFORM, 0)); @@ -505,6 +561,7 @@ impl Render for Graphic { Graphic::RasterGPU(list) => list.collect_metadata(metadata, footprint, element_id), Graphic::Color(list) => list.collect_metadata(metadata, footprint, element_id), Graphic::Gradient(list) => list.collect_metadata(metadata, footprint, element_id), + Graphic::Text(list) => list.collect_metadata(metadata, footprint, element_id), } } @@ -516,6 +573,7 @@ impl Render for Graphic { Graphic::RasterGPU(list) => list.add_upstream_click_targets(click_targets), Graphic::Color(list) => list.add_upstream_click_targets(click_targets), Graphic::Gradient(list) => list.add_upstream_click_targets(click_targets), + Graphic::Text(list) => list.add_upstream_click_targets(click_targets), } } @@ -527,6 +585,7 @@ impl Render for Graphic { Graphic::RasterGPU(list) => list.add_upstream_outline_targets(outlines), Graphic::Color(list) => list.add_upstream_outline_targets(outlines), Graphic::Gradient(list) => list.add_upstream_outline_targets(outlines), + Graphic::Text(list) => list.add_upstream_outline_targets(outlines), } } @@ -538,6 +597,7 @@ impl Render for Graphic { Graphic::RasterGPU(list) => list.contains_artboard(), Graphic::Color(list) => list.contains_artboard(), Graphic::Gradient(list) => list.contains_artboard(), + Graphic::Text(_) => false, } } @@ -549,6 +609,7 @@ impl Render for Graphic { Graphic::RasterGPU(_) => (), Graphic::Color(_) => (), Graphic::Gradient(_) => (), + Graphic::Text(_) => (), } } } @@ -2040,6 +2101,507 @@ impl Render for List { } } +/// Helper struct to write path data to a string +struct SvgGlyphPen { + d: String, + ox: f64, + oy: f64, + tilt_tan: f64, +} + +impl SvgGlyphPen { + #[inline] + fn px(&self, x: f32, y: f32) -> f64 { + self.ox + x as f64 + (y as f64 * self.tilt_tan) + } + + #[inline] + fn py(&self, y: f32) -> f64 { + self.oy - y as f64 + } +} + +impl OutlinePen for SvgGlyphPen { + fn move_to(&mut self, x: f32, y: f32) { + write!(self.d, "M {} {} ", self.px(x, y), self.py(y)).ok(); + } + fn line_to(&mut self, x: f32, y: f32) { + write!(self.d, "L {} {} ", self.px(x, y), self.py(y)).ok(); + } + fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) { + write!(self.d, "Q {} {} {} {} ", self.px(x1, y1), self.py(y1), self.px(x, y), self.py(y)).ok(); + } + fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) { + write!(self.d, "C {} {} {} {} {} {} ", self.px(x1, y1), self.py(y1), self.px(x2, y2), self.py(y2), self.px(x, y), self.py(y)).ok(); + } + fn close(&mut self) { + self.d.push_str("Z "); + } +} + +/// Helper struct to build a `kurbo::BezPath` for Vello rendering. +struct VelloPen<'a> { + path: &'a mut BezPath, + ox: f64, + oy: f64, + tilt_tan: f64, +} + +impl VelloPen<'_> { + #[inline] + fn px(&self, x: f32, y: f32) -> f64 { + self.ox + x as f64 + (y as f64 * self.tilt_tan) + } + + #[inline] + fn py(&self, y: f32) -> f64 { + self.oy - y as f64 + } +} + +impl OutlinePen for VelloPen<'_> { + fn move_to(&mut self, x: f32, y: f32) { + self.path.move_to((self.px(x, y), self.py(y))); + } + fn line_to(&mut self, x: f32, y: f32) { + self.path.line_to((self.px(x, y), self.py(y))); + } + fn quad_to(&mut self, cx: f32, cy: f32, x: f32, y: f32) { + self.path.quad_to((self.px(cx, cy), self.py(cy)), (self.px(x, y), self.py(y))); + } + fn curve_to(&mut self, cx1: f32, cy1: f32, cx2: f32, cy2: f32, x: f32, y: f32) { + self.path.curve_to((self.px(cx1, cy1), self.py(cy1)), (self.px(cx2, cy2), self.py(cy2)), (self.px(x, y), self.py(y))); + } + fn close(&mut self) { + self.path.close_path(); + } +} + +/// Registers all fonts from `RENDER_FONTS` that aren't yet in `FONT_CTX`. +fn ensure_fonts_registered(font_ctx: &mut parley::FontContext) { + REGISTERED_FONTS.with(|reg| { + let mut reg = reg.borrow_mut(); + RENDER_FONTS.with(|rf| { + for (family, style, hash, bytes) in rf.borrow().iter() { + if reg.insert(*hash) { + struct ArcBytes(std::sync::Arc<[u8]>); + impl AsRef<[u8]> for ArcBytes { + fn as_ref(&self) -> &[u8] { + &self.0 + } + } + let font_data: std::sync::Arc + Send + Sync> = std::sync::Arc::new(ArcBytes(bytes.clone())); + let families = font_ctx.collection.register_fonts(parley::fontique::Blob::new(font_data), None); + + if let Some((_, fonts_info)) = families.first() { + if let Some(font_info) = fonts_info.first() { + FONT_INFO_CACHE.with(|cache| { + cache.borrow_mut().insert((family.clone(), style.clone()), font_info.clone()); + }); + } + } + } + } + }); + }); +} + +const DEFAULT_FONT_FAMILY: &str = "Lato"; +const DEFAULT_FONT_SIZE: f64 = 24.; + +impl Render for List { + fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { + for index in 0..self.len() { + let Some(text) = self.element(index) else { continue }; + if text.is_empty() { + continue; + } + + let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); + let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.); + let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.); + let blend_mode_attr: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index); + let font_family: String = self.attribute_cloned_or(ATTR_FONT_FAMILY, index, DEFAULT_FONT_FAMILY.to_string()); + let font_style: String = self.attribute_cloned_or(ATTR_FONT_STYLE, index, "Regular".to_string()); + let font_size: f64 = self.attribute_cloned_or(ATTR_FONT_SIZE, index, DEFAULT_FONT_SIZE); + let line_height: f64 = self.attribute_cloned_or(ATTR_TEXT_LINE_HEIGHT, index, 1.2); + let char_spacing: f64 = self.attribute_cloned_or(ATTR_TEXT_CHARACTER_SPACING, index, 0.); + let max_width: Option = self.attribute_cloned_or(ATTR_TEXT_MAX_WIDTH, index, None); + let max_height: Option = self.attribute_cloned_or(ATTR_TEXT_MAX_HEIGHT, index, None); + let tilt: f64 = self.attribute_cloned_or(ATTR_TEXT_TILT, index, 0.); + let align_u8: u8 = self.attribute_cloned_or(ATTR_TEXT_ALIGN, index, 0_u8); + let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; + + let (parley_align, last_line_correction) = match align_u8 { + 1 => (parley::Alignment::Center, None), + 2 => (parley::Alignment::Right, None), + 3 => (parley::Alignment::Justify, Some(parley::Alignment::Left)), + 4 => (parley::Alignment::Justify, Some(parley::Alignment::Center)), + 5 => (parley::Alignment::Justify, Some(parley::Alignment::Right)), + 6 => (parley::Alignment::Justify, Some(parley::Alignment::Justify)), + _ => (parley::Alignment::Left, None), + }; + + let mut glyph_paths: Vec = Vec::new(); + + FONT_CTX.with(|ctx| { + let Ok(mut ctx) = ctx.try_borrow_mut() else { return }; + let (font_ctx, layout_ctx) = &mut *ctx; + + ensure_fonts_registered(font_ctx); + + let mut builder = layout_ctx.ranged_builder(font_ctx, text, 1.0, false); + builder.push_default(StyleProperty::FontSize(font_size as f32)); + builder.push_default(StyleProperty::FontFamily(parley::FontFamily::Single(parley::FontFamilyName::Named(Cow::Borrowed( + font_family.as_str(), + ))))); + builder.push_default(StyleProperty::LetterSpacing(char_spacing as f32)); + builder.push_default(LineHeight::FontSizeRelative(line_height as f32)); + + FONT_INFO_CACHE.with(|cache| { + let cache = cache.borrow(); + if let Some(font_info) = cache.get(&(font_family.clone(), font_style.clone())) { + builder.push_default(StyleProperty::FontWeight(font_info.weight())); + builder.push_default(StyleProperty::FontStyle(font_info.style())); + builder.push_default(StyleProperty::FontWidth(font_info.width())); + } + }); + + let mut layout = builder.build(text); + let max_width_f32 = max_width.map(|w| w as f32); + let alignment_width = max_width_f32.unwrap_or_else(|| layout.full_width()); + layout.break_all_lines(max_width_f32); + layout.align(parley_align, AlignmentOptions::default()); + + let tilt_tan = tilt.to_radians().tan(); + + for line in layout.lines() { + let range = line.text_range(); + let is_last_para_line = range.end == text.len() || text.get(range.clone()).is_some_and(|s| s.ends_with('\n')); + + let (x_offset, space_extra) = if let (true, Some(correction)) = (is_last_para_line, last_line_correction) { + let metrics = line.metrics(); + let content_advance = metrics.advance - metrics.trailing_whitespace; + let free_space = alignment_width - content_advance; + // Correction is needed because Parley doesn't remove trailing whitespaces + match correction { + parley::Alignment::Center => (free_space * 0.5, 0.), + parley::Alignment::Right => (free_space, 0.), + parley::Alignment::Justify => { + let line_text = text.get(range.clone()).unwrap_or(""); + let trailing_len = line_text.len() - line_text.trim_end().len(); + let visible_end_index = range.end - trailing_len; + + let space_count: usize = line + .runs() + .map(|run| run.clusters().filter(|c| c.is_space_or_nbsp() && c.text_range().start < visible_end_index).count()) + .sum(); + let extra = if space_count > 0 { free_space / space_count as f32 } else { 0. }; + (0., extra) + } + _ => (0., 0.), + } + } else { + (0., 0.) + }; + + for item in line.items() { + let PositionedLayoutItem::GlyphRun(glyph_run) = item else { continue }; + if max_height.is_some_and(|mh| glyph_run.baseline() > mh as f32) { + continue; + } + + let mut run_x = glyph_run.offset() + x_offset; + let run_y = glyph_run.baseline(); + let run = glyph_run.run(); + let font = run.font(); + let font_size_pts = run.font_size(); + let normalized_coords: Vec = run.normalized_coords().iter().map(|c| NormalizedCoord::from_bits(*c)).collect(); + + let font_data = font.data.as_ref(); + let Ok(font_ref) = SkrifaFontRef::from_index(font_data, font.index) else { continue }; + let outlines = font_ref.outline_glyphs(); + + let mut pen = SvgGlyphPen { + d: String::new(), + ox: 0., + oy: 0., + tilt_tan, + }; + for glyph in glyph_run.glyphs() { + let ox = (run_x + glyph.x) as f64; + let oy = (run_y - glyph.y) as f64; + run_x += glyph.advance; + + let glyph_id = GlyphId::from(glyph.id); + let Some(outline) = outlines.get(glyph_id) else { continue }; + let settings = DrawSettings::unhinted(Size::new(font_size_pts), LocationRef::new(&normalized_coords)); + + pen.d.clear(); + pen.ox = ox; + pen.oy = oy; + if outline.draw(settings, &mut pen).is_ok() && !pen.d.is_empty() { + glyph_paths.push(pen.d.clone()); + } else if space_extra != 0. && glyph.advance > 0. { + run_x += space_extra; + } + } + } + } + }); + + if glyph_paths.is_empty() { + continue; + } + + // Wrap all glyph elements in a with the item's transform/opacity/blend-mode. + render.parent_tag( + "g", + |attributes| { + let matrix = format_transform_matrix(transform); + if !matrix.is_empty() { + attributes.push("transform", matrix); + } + if opacity < 1. { + attributes.push("opacity", opacity.to_string()); + } + if blend_mode_attr != BlendMode::default() { + attributes.push("style", blend_mode_attr.render()); + } + }, + |render| { + for path_d in glyph_paths { + render.leaf_tag("path", |attributes| { + attributes.push("d", path_d); + if let RenderMode::Outline = render_params.render_mode { + attributes.push("fill", "none"); + attributes.push("stroke", "black"); + attributes.push("stroke-width", "1"); + } else { + attributes.push("fill", "black"); + attributes.push("fill-rule", "nonzero"); + } + }); + } + }, + ); + } + } + + fn render_to_vello(&self, scene: &mut Scene, transform: DAffine2, _context: &mut RenderContext, render_params: &RenderParams) { + for index in 0..self.len() { + let Some(text) = self.element(index) else { continue }; + if text.is_empty() { + continue; + } + + let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); + let font_family: String = self.attribute_cloned_or(ATTR_FONT_FAMILY, index, DEFAULT_FONT_FAMILY.to_string()); + let font_style: String = self.attribute_cloned_or(ATTR_FONT_STYLE, index, "Regular".to_string()); + let font_size: f64 = self.attribute_cloned_or(ATTR_FONT_SIZE, index, DEFAULT_FONT_SIZE); + let line_height: f64 = self.attribute_cloned_or(ATTR_TEXT_LINE_HEIGHT, index, 1.2); + let char_spacing: f64 = self.attribute_cloned_or(ATTR_TEXT_CHARACTER_SPACING, index, 0.); + let max_width: Option = self.attribute_cloned_or(ATTR_TEXT_MAX_WIDTH, index, None); + let max_height: Option = self.attribute_cloned_or(ATTR_TEXT_MAX_HEIGHT, index, None); + let tilt: f64 = self.attribute_cloned_or(ATTR_TEXT_TILT, index, 0.); + let align_u8: u8 = self.attribute_cloned_or(ATTR_TEXT_ALIGN, index, 0_u8); + let blend_mode_attr: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index); + let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.); + let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.); + let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; + + let (parley_align, last_line_correction) = match align_u8 { + 1 => (parley::Alignment::Center, None), + 2 => (parley::Alignment::Right, None), + 3 => (parley::Alignment::Justify, Some(parley::Alignment::Left)), + 4 => (parley::Alignment::Justify, Some(parley::Alignment::Center)), + 5 => (parley::Alignment::Justify, Some(parley::Alignment::Right)), + 6 => (parley::Alignment::Justify, Some(parley::Alignment::Justify)), + _ => (parley::Alignment::Left, None), + }; + + let affine = Affine::new((transform * item_transform).to_cols_array()); + + FONT_CTX.with(|ctx| { + let Ok(mut ctx) = ctx.try_borrow_mut() else { return }; + let (font_ctx, layout_ctx) = &mut *ctx; + + ensure_fonts_registered(font_ctx); + + let mut builder = layout_ctx.ranged_builder(font_ctx, text, 1.0, false); + builder.push_default(StyleProperty::FontSize(font_size as f32)); + builder.push_default(StyleProperty::FontFamily(parley::FontFamily::Single(parley::FontFamilyName::Named(Cow::Borrowed( + font_family.as_str(), + ))))); + builder.push_default(StyleProperty::LetterSpacing(char_spacing as f32)); + builder.push_default(LineHeight::FontSizeRelative(line_height as f32)); + + FONT_INFO_CACHE.with(|cache| { + let cache = cache.borrow(); + if let Some(font_info) = cache.get(&(font_family.clone(), font_style.clone())) { + builder.push_default(StyleProperty::FontWeight(font_info.weight())); + builder.push_default(StyleProperty::FontStyle(font_info.style())); + builder.push_default(StyleProperty::FontWidth(font_info.width())); + } + }); + + let mut layout = builder.build(text); + let max_width_f32 = max_width.map(|w| w as f32); + let alignment_width = max_width_f32.unwrap_or_else(|| layout.full_width()); + layout.break_all_lines(max_width_f32); + layout.align(parley_align, AlignmentOptions::default()); + + let needs_layer = opacity < 1. || blend_mode_attr != BlendMode::default(); + if needs_layer { + let blending = peniko::BlendMode::new(blend_mode_attr.to_peniko(), peniko::Compose::SrcOver); + let padding = font_size; + let bounds = kurbo::Rect::new(-padding, -padding, layout.full_width() as f64 + padding, layout.height() as f64 + padding); + let transformed_bounds = affine.transform_rect_bbox(bounds); + scene.push_layer(peniko::Fill::NonZero, blending, opacity, kurbo::Affine::IDENTITY, &transformed_bounds); + } + + let tilt_tan = tilt.to_radians().tan(); + + for line in layout.lines() { + let range = line.text_range(); + let is_last_para_line = range.end == text.len() || text.get(range.clone()).is_some_and(|s| s.ends_with('\n')); + + let (x_offset, space_extra) = if let (true, Some(correction)) = (is_last_para_line, last_line_correction) { + let metrics = line.metrics(); + let content_advance = metrics.advance - metrics.trailing_whitespace; + let free_space = alignment_width - content_advance; + + match correction { + parley::Alignment::Center => (free_space * 0.5, 0.), + parley::Alignment::Right => (free_space, 0.), + parley::Alignment::Justify => { + let line_text = text.get(range.clone()).unwrap_or(""); + let trailing_len = line_text.len() - line_text.trim_end().len(); + let visible_end_index = range.end - trailing_len; + + let space_count: usize = line + .runs() + .map(|run| run.clusters().filter(|c| c.is_space_or_nbsp() && c.text_range().start < visible_end_index).count()) + .sum(); + let extra = if space_count > 0 { free_space / space_count as f32 } else { 0. }; + (0., extra) + } + _ => (0., 0.), + } + } else { + (0., 0.) + }; + + for item in line.items() { + let PositionedLayoutItem::GlyphRun(glyph_run) = item else { continue }; + if max_height.is_some_and(|mh| glyph_run.baseline() > mh as f32) { + continue; + } + + let mut run_x = glyph_run.offset() + x_offset; + let run_y = glyph_run.baseline(); + let run = glyph_run.run(); + let font = run.font(); + let font_size_pts = run.font_size(); + let normalized_coords: Vec = run.normalized_coords().iter().map(|c| NormalizedCoord::from_bits(*c)).collect(); + + let font_data = font.data.as_ref(); + let Ok(font_ref) = SkrifaFontRef::from_index(font_data, font.index) else { continue }; + let outlines = font_ref.outline_glyphs(); + + let mut bez_path = BezPath::new(); + for glyph in glyph_run.glyphs() { + let ox = (run_x + glyph.x) as f64; + let oy = (run_y - glyph.y) as f64; + run_x += glyph.advance; + + let glyph_id = GlyphId::from(glyph.id); + let Some(outline) = outlines.get(glyph_id) else { continue }; + let settings = DrawSettings::unhinted(Size::new(font_size_pts), LocationRef::new(&normalized_coords)); + + bez_path.truncate(0); + let mut pen = VelloPen { + path: &mut bez_path, + ox, + oy, + tilt_tan, + }; + if outline.draw(settings, &mut pen).is_ok() && !bez_path.elements().is_empty() { + if let RenderMode::Outline = render_params.render_mode { + let (outline_stroke, outline_color) = get_outline_styles(render_params); + scene.stroke(&outline_stroke, affine, outline_color, None, &bez_path); + } else { + scene.fill(peniko::Fill::NonZero, affine, peniko::Color::BLACK, None, &bez_path); + } + } else if space_extra != 0. && glyph.advance > 0. { + run_x += space_extra; + } + } + } + } + + if needs_layer { + scene.pop_layer(); + } + }); + } + } + fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option) { + let Some(element_id) = element_id else { return }; + metadata.upstream_footprints.insert(element_id, footprint); + if !self.is_empty() { + metadata.local_transforms.insert(element_id, self.attribute_cloned_or_default(ATTR_TRANSFORM, 0)); + } + } + + fn add_upstream_click_targets(&self, click_targets: &mut Vec) { + for index in 0..self.len() { + let Some(text) = self.element(index) else { continue }; + let font_size: f64 = self.attribute_cloned_or(ATTR_FONT_SIZE, index, DEFAULT_FONT_SIZE); + let font_family: String = self.attribute_cloned_or(ATTR_FONT_FAMILY, index, DEFAULT_FONT_FAMILY.to_string()); + let line_height: f64 = self.attribute_cloned_or(ATTR_TEXT_LINE_HEIGHT, index, 1.2); + let char_spacing: f64 = self.attribute_cloned_or(ATTR_TEXT_CHARACTER_SPACING, index, 0.); + let max_width: Option = self.attribute_cloned_or(ATTR_TEXT_MAX_WIDTH, index, None); + let max_height: Option = self.attribute_cloned_or(ATTR_TEXT_MAX_HEIGHT, index, None); + let align_u8: u8 = self.attribute_cloned_or(ATTR_TEXT_ALIGN, index, 0_u8); + let parley_align = match align_u8 { + 1 => parley::Alignment::Center, + 2 => parley::Alignment::Right, + 3..=6 => parley::Alignment::Justify, + _ => parley::Alignment::Left, + }; + let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); + + // Falls back to a single-em square if fonts are not yet registered. + let (width, height) = FONT_CTX + .with(|ctx| { + let Ok(mut ctx) = ctx.try_borrow_mut() else { return None }; + let (font_ctx, layout_ctx) = &mut *ctx; + ensure_fonts_registered(font_ctx); + let mut builder = layout_ctx.ranged_builder(font_ctx, text, 1.0, false); + builder.push_default(StyleProperty::FontSize(font_size as f32)); + builder.push_default(StyleProperty::FontFamily(parley::FontFamily::Single(parley::FontFamilyName::Named(Cow::Borrowed( + font_family.as_str(), + ))))); + builder.push_default(StyleProperty::LetterSpacing(char_spacing as f32)); + builder.push_default(LineHeight::FontSizeRelative(line_height as f32)); + let mut layout = builder.build(text); + layout.break_all_lines(max_width.map(|w| w as f32)); + layout.align(parley_align, AlignmentOptions::default()); + let w = max_width.unwrap_or_else(|| layout.width() as f64); + let h = max_height.unwrap_or_else(|| layout.height() as f64); + Some((w, h)) + }) + .unwrap_or((font_size, font_size)); + + let subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::new(width, height)); + let mut target = ClickTarget::new_with_subpath(subpath, 0.); + target.apply_transform(transform); + click_targets.push(target); + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum SvgSegment { Slice(&'static str), diff --git a/node-graph/nodes/gstd/src/render_node.rs b/node-graph/nodes/gstd/src/render_node.rs index 121131a062..9afded17d9 100644 --- a/node-graph/nodes/gstd/src/render_node.rs +++ b/node-graph/nodes/gstd/src/render_node.rs @@ -39,6 +39,7 @@ async fn render_intermediate<'a: 'n, T: 'static + Render + WasmNotSend + Send + Context -> List>, Context -> List, Context -> List, + Context -> List, )] data: impl Node, Output = T>, ) -> RenderIntermediate { diff --git a/node-graph/nodes/gstd/src/text.rs b/node-graph/nodes/gstd/src/text.rs index 89c557e7ca..ccf68fe722 100644 --- a/node-graph/nodes/gstd/src/text.rs +++ b/node-graph/nodes/gstd/src/text.rs @@ -1,5 +1,9 @@ use core_types::Ctx; use core_types::list::List; +use core_types::{ + ATTR_EDITOR_LAYER_PATH, ATTR_FONT_FAMILY, ATTR_FONT_SIZE, ATTR_FONT_STYLE, ATTR_TEXT_ALIGN, ATTR_TEXT_CHARACTER_SPACING, ATTR_TEXT_FONT, ATTR_TEXT_LINE_HEIGHT, ATTR_TEXT_MAX_HEIGHT, + ATTR_TEXT_MAX_WIDTH, ATTR_TEXT_TILT, ATTR_TRANSFORM, +}; use graph_craft::application_io::PlatformEditorApi; use graphic_types::Vector; pub use text_nodes::*; @@ -75,3 +79,146 @@ fn text<'i: 'n>( to_path(&text, &font, &editor_resources.font_cache, typesetting, separate_glyphs) } + +/// Produces a styled `List` carrying all typographic attributes. +#[node_macro::node(category("Text"))] +fn text_layer( + _: impl Ctx, + _primary: (), + /// The text content to display. + #[widget(ParsedWidgetOverride::Custom = "text_area")] + #[default("Lorem ipsum")] + text: String, + /// The typeface used to render the text. + #[widget(ParsedWidgetOverride::Custom = "text_font")] + font: Font, + /// Font size in document-space pixels. + #[unit(" px")] + #[default(24.)] + #[hard_min(1.)] + size: f64, + /// Line height ratio relative to the font size. 1.2 is the typical default for body copy. + #[unit("x")] + #[hard_min(0.)] + #[step(0.1)] + #[default(1.2)] + line_height: f64, + /// Additional spacing in document-space pixels added between every character pair. + #[unit(" px")] + #[step(0.1)] + character_spacing: f64, + /// Enables the maximum width constraint so lines can wrap. + #[widget(ParsedWidgetOverride::Hidden)] + has_max_width: bool, + /// Maximum line-wrap width in document-space pixels. + #[unit(" px")] + #[hard_min(1.)] + #[widget(ParsedWidgetOverride::Custom = "optional_f64")] + max_width: f64, + /// Enables the maximum height constraint so excess lines are clipped. + #[widget(ParsedWidgetOverride::Hidden)] + has_max_height: bool, + /// Maximum block height in document-space pixels; lines whose baseline exceeds this are not drawn. + #[unit(" px")] + #[hard_min(1.)] + #[widget(ParsedWidgetOverride::Custom = "optional_f64")] + max_height: f64, + /// Faux-italic slant angle in degrees. + #[unit("°")] + #[hard_min(-85.)] + #[hard_max(85.)] + tilt: f64, + /// Horizontal alignment of each line within the text block. + #[widget(ParsedWidgetOverride::Custom = "text_align")] + align: TextAlign, +) -> List { + const DEFAULT_FONT_SIZE: f64 = 24.; + const DEFAULT_LINE_HEIGHT: f64 = 1.2; + + let mut list = List::new_from_element(text); + + // Insert only when value deviates from its default as each stored attribute has runtime cost. + + if font != Font::default() { + list.set_attribute(ATTR_TEXT_FONT, 0, font.clone()); + list.set_attribute(ATTR_FONT_FAMILY, 0, font.font_family.clone()); + list.set_attribute(ATTR_FONT_STYLE, 0, font.font_style.clone()); + } + if (size - DEFAULT_FONT_SIZE).abs() > f64::EPSILON { + list.set_attribute(ATTR_FONT_SIZE, 0, size); + } + if (line_height - DEFAULT_LINE_HEIGHT).abs() > f64::EPSILON { + list.set_attribute(ATTR_TEXT_LINE_HEIGHT, 0, line_height); + } + if character_spacing != 0. { + list.set_attribute(ATTR_TEXT_CHARACTER_SPACING, 0, character_spacing); + } + if has_max_width { + list.set_attribute(ATTR_TEXT_MAX_WIDTH, 0, Some(max_width)); + } + if has_max_height { + list.set_attribute(ATTR_TEXT_MAX_HEIGHT, 0, Some(max_height)); + } + if tilt != 0. { + list.set_attribute(ATTR_TEXT_TILT, 0, tilt); + } + if align != TextAlign::default() { + list.set_attribute(ATTR_TEXT_ALIGN, 0, align); + } + + list +} + +/// Converts a styled `List` into vector geometry. +/// Each string item is independently shaped by Parley and vectorised via skrifa. +#[node_macro::node(category("Text"))] +fn text_to_vector<'i: 'n>( + _: impl Ctx, + /// A styled list of text strings produced by the **Text Layer** node (or any other `List` source). + #[implementations(List)] + strings: List, + /// The Graphite editor's source for global font resources. + #[scope("editor-api")] + #[widget(ParsedWidgetOverride::Hidden)] + editor_resources: &'i PlatformEditorApi, + /// When enabled, each glyph is emitted as its own vector item instead of a single compound path per string. + separate_glyphs: bool, +) -> List { + let mut result = List::new(); + + for index in 0..strings.len() { + let Some(text) = strings.element(index) else { continue }; + if text.is_empty() { + continue; + } + + let font: Font = strings.attribute_cloned_or(ATTR_TEXT_FONT, index, Font::default()); + + let typesetting = TypesettingConfig { + font_size: strings.attribute_cloned_or(ATTR_FONT_SIZE, index, 24.), + line_height_ratio: strings.attribute_cloned_or(ATTR_TEXT_LINE_HEIGHT, index, 1.2), + character_spacing: strings.attribute_cloned_or(ATTR_TEXT_CHARACTER_SPACING, index, 0.), + max_width: strings.attribute_cloned_or::>(ATTR_TEXT_MAX_WIDTH, index, None), + max_height: strings.attribute_cloned_or::>(ATTR_TEXT_MAX_HEIGHT, index, None), + tilt: strings.attribute_cloned_or(ATTR_TEXT_TILT, index, 0.), + align: strings.attribute_cloned_or(ATTR_TEXT_ALIGN, index, TextAlign::default()), + }; + + let vectors = to_path(text, &font, &editor_resources.font_cache, typesetting, separate_glyphs); + let transform = strings.attribute_cloned_or_default::(ATTR_TRANSFORM, index); + let layer_path = strings.attribute_cloned_or_default::>(ATTR_EDITOR_LAYER_PATH, index); + + for mut item in vectors.into_iter() { + if transform != glam::DAffine2::IDENTITY { + let local = item.attribute_cloned_or_default::(ATTR_TRANSFORM); + item.set_attribute(ATTR_TRANSFORM, transform * local); + } + if !layer_path.is_empty() { + item.set_attribute(ATTR_EDITOR_LAYER_PATH, layer_path.clone()); + } + result.push(item); + } + } + + result +} diff --git a/node-graph/nodes/path-bool/src/lib.rs b/node-graph/nodes/path-bool/src/lib.rs index 14c0025d52..9fffc0a704 100644 --- a/node-graph/nodes/path-bool/src/lib.rs +++ b/node-graph/nodes/path-bool/src/lib.rs @@ -278,6 +278,7 @@ fn flatten_vector(graphic_list: &List) -> List { Item::from_parts(element, attributes) }) .collect::>(), + Graphic::Text(_) => Vec::new(), } }) .collect() diff --git a/node-graph/nodes/text/src/font_cache.rs b/node-graph/nodes/text/src/font_cache.rs index 258452ebc4..cc5353c792 100644 --- a/node-graph/nodes/text/src/font_cache.rs +++ b/node-graph/nodes/text/src/font_cache.rs @@ -78,6 +78,9 @@ impl Default for Font { pub struct FontCache { /// Actual font file data used for rendering a font font_file_data: HashMap>, + /// Built once per `insert` call and never re-allocated. + #[cfg_attr(feature = "serde", serde(skip))] + arc_cache: HashMap>, } impl std::fmt::Debug for FontCache { @@ -131,8 +134,18 @@ impl FontCache { /// Insert a new font into the cache pub fn insert(&mut self, font: Font, data: Vec) { + let arc: Arc<[u8]> = Arc::from(data.as_slice()); + self.arc_cache.insert(font.clone(), arc); self.font_file_data.insert(font.clone(), data); } + + /// Iterate over all loaded fonts, yielding a zero-copy `Arc<[u8]>` reference to each font's bytes. + pub fn iter_fonts(&self) -> impl Iterator)> { + self.font_file_data.iter().map(|(font, bytes)| { + let arc = self.arc_cache.get(font).cloned().unwrap_or_else(|| Arc::from(bytes.as_slice())); + (font.font_family.as_str(), font.font_style.as_str(), arc) + }) + } } // TODO: Eventually remove this migration document upgrade code