From d028d1d80f6f5040e8d74fe8d9f136e02c7b8648 Mon Sep 17 00:00:00 2001 From: Annonnymmousss Date: Wed, 13 May 2026 03:08:36 +0530 Subject: [PATCH 01/14] feat: Render List as raw paths in SVG and Vell mode --- Cargo.lock | 2 + .../data_panel/data_panel_message_handler.rs | 2 + node-graph/libraries/core-types/src/lib.rs | 2 +- node-graph/libraries/core-types/src/list.rs | 6 + .../core-types/src/render_complexity.rs | 6 + .../libraries/graphic-types/src/graphic.rs | 29 ++ node-graph/libraries/rendering/Cargo.toml | 2 + .../libraries/rendering/src/renderer.rs | 343 +++++++++++++++++- node-graph/nodes/gstd/src/render_node.rs | 1 + node-graph/nodes/path-bool/src/lib.rs | 1 + 10 files changed, 389 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 88e882908e..d39ac335e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4597,7 +4597,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/node-graph/libraries/core-types/src/lib.rs b/node-graph/libraries/core-types/src/lib.rs index fcebee42b6..0ad5bc23f2 100644 --- a/node-graph/libraries/core-types/src/lib.rs +++ b/node-graph/libraries/core-types/src/lib.rs @@ -25,7 +25,7 @@ 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_GRADIENT_TYPE, ATTR_LOCATION, ATTR_NAME, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_SPREAD_METHOD, ATTR_START, 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 976d6ca7f5..bbf9073b78 100644 --- a/node-graph/libraries/core-types/src/list.rs +++ b/node-graph/libraries/core-types/src/list.rs @@ -83,6 +83,12 @@ pub const ATTR_FILL: &str = "fill"; /// Vector graphics object's stroke paint, of type List where T is any graphic type. pub const ATTR_STROKE: &str = "stroke"; +/// Text item's font family (`String`, implicit default `"sans-serif"`). +pub const ATTR_FONT_FAMILY: &str = "font_family"; + +/// Text item's font size in document-space units (`f64`, implicit default `16.`). +pub const ATTR_FONT_SIZE: &str = "font_size"; + // =========================== // Implicit attribute defaults // =========================== 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 7d5267d352..eb3a28286e 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 { @@ -325,6 +338,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; @@ -381,6 +400,12 @@ impl IntoGraphicList for List { } } +impl IntoGraphicList for List { + fn into_graphic_list(self) -> List { + List::new_from_element(Graphic::Text(self)) + } +} + impl IntoGraphicList for DAffine2 { fn into_graphic_list(self) -> List { List::new_from_element(Graphic::default()) @@ -450,6 +475,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), } } @@ -545,6 +571,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::None, } } @@ -556,6 +583,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::None, } } } @@ -585,6 +613,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 c04d787592..366c2281ff 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -13,7 +13,7 @@ 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_GRADIENT_TYPE, ATTR_LOCATION, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_SPREAD_METHOD, ATTR_TRANSFORM, }; use dyn_any::DynAny; use glam::{DAffine2, DVec2}; @@ -25,8 +25,16 @@ 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, StrokeAlign, StrokeCap, StrokeJoin}; use graphic_types::{Artboard, Graphic, Vector}; -use kurbo::{Affine, Cap, Join, Shape, StrokeOpts}; +use kurbo::{Affine, BezPath, Cap, Join, Shape, StrokeOpts}; use num_traits::Zero; +use parley::{FontContext, FontFamily, FontStack, LayoutContext, 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::ops::Deref; @@ -34,6 +42,27 @@ 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()); +} + +// Set the font bytes available to the renderer for the current execution. +pub fn set_render_fonts(fonts: impl IntoIterator)>) { + let slice: Arc<[(String, Arc<[u8]>)]> = fonts.into_iter().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 { @@ -224,16 +253,26 @@ pub struct RenderParams { pub artboard_background: Option, /// Viewport zoom level (document-space scale). Used to compute constant viewport-pixel stroke widths in Outline mode. pub viewport_zoom: f64, + // All loaded font bytes extracted from the `FontCache`, keyed by CSS family name. + pub available_fonts: Arc<[(String, Arc<[u8]>)]>, } impl RenderParams { pub fn for_clipper(&self) -> Self { - Self { for_mask: true, ..*self } + Self { + for_mask: true, + available_fonts: self.available_fonts.clone(), + ..*self + } } pub fn for_alignment(&self, transform: DAffine2) -> Self { let alignment_parent_transform = Some(transform); - Self { alignment_parent_transform, ..*self } + Self { + alignment_parent_transform, + available_fonts: self.available_fonts.clone(), + ..*self + } } pub fn for_pattern(&self) -> Self { @@ -547,6 +586,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), } } @@ -558,6 +598,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), } } @@ -606,6 +647,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)); @@ -621,6 +670,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), } } @@ -632,6 +682,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), } } @@ -643,6 +694,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), } } @@ -654,6 +706,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, } } @@ -665,6 +718,7 @@ impl Render for Graphic { Graphic::RasterGPU(_) => (), Graphic::Color(_) => (), Graphic::Gradient(_) => (), + Graphic::Text(_) => (), } } } @@ -2238,6 +2292,287 @@ impl Render for List { } } +/// Helper struct to write path data to a string +struct SvgGlyphPen { + d: String, + ox: f64, + oy: f64, +} + +impl SvgGlyphPen { + #[inline] + fn px(&self, x: f32) -> f64 { + self.ox + x as f64 + } + + #[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), self.py(y)).ok(); + } + fn line_to(&mut self, x: f32, y: f32) { + write!(self.d, "L {} {} ", self.px(x), self.py(y)).ok(); + } + fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) { + write!(self.d, "Q {} {} {} {} ", self.px(x1), self.py(y1), self.px(x), 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), self.py(y1), self.px(x2), self.py(y2), self.px(x), 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, +} + +impl OutlinePen for VelloPen<'_> { + fn move_to(&mut self, x: f32, y: f32) { + self.path.move_to((self.ox + x as f64, self.oy - y as f64)); + } + fn line_to(&mut self, x: f32, y: f32) { + self.path.line_to((self.ox + x as f64, self.oy - y as f64)); + } + fn quad_to(&mut self, cx: f32, cy: f32, x: f32, y: f32) { + self.path.quad_to((self.ox + cx as f64, self.oy - cy as f64), (self.ox + x as f64, self.oy - y as f64)); + } + fn curve_to(&mut self, cx1: f32, cy1: f32, cx2: f32, cy2: f32, x: f32, y: f32) { + self.path.curve_to( + (self.ox + cx1 as f64, self.oy - cy1 as f64), + (self.ox + cx2 as f64, self.oy - cy2 as f64), + (self.ox + x as f64, self.oy - y as f64), + ); + } + 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 (_, bytes) in rf.borrow().iter() { + let key = bytes.as_ptr() as usize; + if reg.insert(key) { + 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())); + font_ctx.collection.register_fonts(parley::fontique::Blob::new(font_data), None); + } + } + }); + }); +} + +const DEFAULT_FONT_FAMILY: &str = "Lato"; +const DEFAULT_FONT_SIZE: f64 = 16.; + +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_size: f64 = self.attribute_cloned_or(ATTR_FONT_SIZE, index, DEFAULT_FONT_SIZE); + let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; + + 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::FontStack(FontStack::Single(FontFamily::Named(Cow::Borrowed(font_family.as_str()))))); + let mut layout = builder.build(text); + layout.break_all_lines(None); + + for line in layout.lines() { + for item in line.items() { + let PositionedLayoutItem::GlyphRun(glyph_run) = item else { continue }; + + let mut run_x = glyph_run.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(); + + 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)); + let mut pen = SvgGlyphPen { d: String::new(), ox, oy }; + if outline.draw(settings, &mut pen).is_ok() && !pen.d.is_empty() { + glyph_paths.push(pen.d); + } + } + } + } + }); + + 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_size: f64 = self.attribute_cloned_or(ATTR_FONT_SIZE, index, DEFAULT_FONT_SIZE); + 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 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::FontStack(FontStack::Single(FontFamily::Named(Cow::Borrowed(font_family.as_str()))))); + let mut layout = builder.build(text); + layout.break_all_lines(None); + + for line in layout.lines() { + for item in line.items() { + let PositionedLayoutItem::GlyphRun(glyph_run) = item else { continue }; + + let mut run_x = glyph_run.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(); + + 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)); + + let mut bez_path = BezPath::new(); + let mut pen = VelloPen { path: &mut bez_path, ox, oy }; + 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 { + let color = peniko::Color::new([0_f32, 0., 0., opacity]); + scene.fill(peniko::Fill::NonZero, affine, color, None, &bez_path); + } + } + } + } + } + }); + } + } + 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 font_size: f64 = self.attribute_cloned_or(ATTR_FONT_SIZE, index, DEFAULT_FONT_SIZE); + let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); + // TODO: temporary stepping stone until the Data Trees (Issue #3779) refactor is complete + let subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::new(font_size * 6., font_size)); + 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/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() From eb3c84fb7f1f9f631191eaa14dcc5ee792ff06bf Mon Sep 17 00:00:00 2001 From: Annonnymmousss Date: Wed, 13 May 2026 03:56:52 +0530 Subject: [PATCH 02/14] chore: code review --- .../libraries/graphic-types/src/graphic.rs | 4 +-- .../libraries/rendering/src/renderer.rs | 27 ++++++++++--------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/node-graph/libraries/graphic-types/src/graphic.rs b/node-graph/libraries/graphic-types/src/graphic.rs index eb3a28286e..e831f3e26f 100644 --- a/node-graph/libraries/graphic-types/src/graphic.rs +++ b/node-graph/libraries/graphic-types/src/graphic.rs @@ -571,7 +571,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::None, + Graphic::Text(_) => RenderBoundingBox::Infinite, } } @@ -583,7 +583,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::None, + Graphic::Text(_) => RenderBoundingBox::Infinite, } } } diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 366c2281ff..d06b273a69 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -253,24 +253,16 @@ pub struct RenderParams { pub artboard_background: Option, /// Viewport zoom level (document-space scale). Used to compute constant viewport-pixel stroke widths in Outline mode. pub viewport_zoom: f64, - // All loaded font bytes extracted from the `FontCache`, keyed by CSS family name. - pub available_fonts: Arc<[(String, Arc<[u8]>)]>, } impl RenderParams { pub fn for_clipper(&self) -> Self { - Self { - for_mask: true, - available_fonts: self.available_fonts.clone(), - ..*self - } + Self { for_mask: true, ..*self } } pub fn for_alignment(&self, transform: DAffine2) -> Self { - let alignment_parent_transform = Some(transform); Self { - alignment_parent_transform, - available_fonts: self.available_fonts.clone(), + alignment_parent_transform: Some(transform), ..*self } } @@ -2493,12 +2485,20 @@ impl Render for List { 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_size: f64 = self.attribute_cloned_or(ATTR_FONT_SIZE, index, DEFAULT_FONT_SIZE); + 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 affine = Affine::new((transform * item_transform).to_cols_array()); + 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 inf_rect = kurbo::Rect::from_origin_size(kurbo::Point::ZERO, kurbo::Size::new(f64::INFINITY, f64::INFINITY)); + scene.push_layer(peniko::Fill::NonZero, blending, opacity, kurbo::Affine::IDENTITY, &inf_rect); + } + FONT_CTX.with(|ctx| { let Ok(mut ctx) = ctx.try_borrow_mut() else { return }; let (font_ctx, layout_ctx) = &mut *ctx; @@ -2542,14 +2542,17 @@ impl Render for List { let (outline_stroke, outline_color) = get_outline_styles(render_params); scene.stroke(&outline_stroke, affine, outline_color, None, &bez_path); } else { - let color = peniko::Color::new([0_f32, 0., 0., opacity]); - scene.fill(peniko::Fill::NonZero, affine, color, None, &bez_path); + scene.fill(peniko::Fill::NonZero, affine, peniko::Color::BLACK, None, &bez_path); } } } } } }); + + if needs_layer { + scene.pop_layer(); + } } } fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option) { From c062da87591b97fa64c1cbfd6d195f36e09bf159 Mon Sep 17 00:00:00 2001 From: Annonnymmousss Date: Fri, 15 May 2026 03:22:45 +0530 Subject: [PATCH 03/14] chore: change the hardcoded layout bounds to parley's --- .../libraries/rendering/src/renderer.rs | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index d06b273a69..fdbf31e6e8 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -2565,10 +2565,27 @@ impl Render for List { 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 transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); - // TODO: temporary stepping stone until the Data Trees (Issue #3779) refactor is complete - let subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::new(font_size * 6., font_size)); + + // 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::FontStack(FontStack::Single(FontFamily::Named(Cow::Borrowed(font_family.as_str()))))); + let mut layout = builder.build(text); + layout.break_all_lines(None); + Some((layout.width() as f64, layout.height() as f64)) + }) + .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); From 10ee655a389ef1fc14ede59e386db80b574d9e25 Mon Sep 17 00:00:00 2001 From: Annonnymmousss Date: Sat, 16 May 2026 13:12:55 +0530 Subject: [PATCH 04/14] chore: code review --- node-graph/libraries/core-types/src/list.rs | 2 +- .../libraries/graphic-types/src/graphic.rs | 7 ++- .../libraries/rendering/src/renderer.rs | 44 ++++++++++++------- 3 files changed, 34 insertions(+), 19 deletions(-) diff --git a/node-graph/libraries/core-types/src/list.rs b/node-graph/libraries/core-types/src/list.rs index bbf9073b78..5bf72070b6 100644 --- a/node-graph/libraries/core-types/src/list.rs +++ b/node-graph/libraries/core-types/src/list.rs @@ -83,7 +83,7 @@ pub const ATTR_FILL: &str = "fill"; /// Vector graphics object's stroke paint, of type List where T is any graphic type. pub const ATTR_STROKE: &str = "stroke"; -/// Text item's font family (`String`, implicit default `"sans-serif"`). +/// Text item's font family (`String`, implicit default `"Lato"`). pub const ATTR_FONT_FAMILY: &str = "font_family"; /// Text item's font size in document-space units (`f64`, implicit default `16.`). diff --git a/node-graph/libraries/graphic-types/src/graphic.rs b/node-graph/libraries/graphic-types/src/graphic.rs index e831f3e26f..d5bf9205cd 100644 --- a/node-graph/libraries/graphic-types/src/graphic.rs +++ b/node-graph/libraries/graphic-types/src/graphic.rs @@ -402,7 +402,12 @@ impl IntoGraphicList for List { impl IntoGraphicList for List { fn into_graphic_list(self) -> List { - List::new_from_element(Graphic::Text(self)) + 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 } } diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index fdbf31e6e8..3953f84d83 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -37,6 +37,7 @@ 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; @@ -44,7 +45,7 @@ use vello::*; // Thread local storage for font bytes thread_local! { - static RENDER_FONTS: RefCell)]>> = RefCell::new(Arc::from([])); + static RENDER_FONTS: RefCell)]>> = RefCell::new(Arc::from([])); } // Thread-local parley font shaping context @@ -54,12 +55,20 @@ thread_local! { // Tracks which font bytes have already been registered into FONT_CTX thread_local! { - static REGISTERED_FONTS: RefCell> = RefCell::new(HashSet::new()); + static REGISTERED_FONTS: RefCell> = RefCell::new(HashSet::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, Arc<[u8]>)]> = fonts.into_iter().collect::>().into(); + let slice: Arc<[(String, u64, Arc<[u8]>)]> = fonts + .into_iter() + .map(|(name, bytes)| { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + bytes.hash(&mut hasher); + (name, hasher.finish(), bytes) + }) + .collect::>() + .into(); RENDER_FONTS.with(|f| *f.borrow_mut() = slice); } @@ -2355,9 +2364,8 @@ fn ensure_fonts_registered(font_ctx: &mut parley::FontContext) { REGISTERED_FONTS.with(|reg| { let mut reg = reg.borrow_mut(); RENDER_FONTS.with(|rf| { - for (_, bytes) in rf.borrow().iter() { - let key = bytes.as_ptr() as usize; - if reg.insert(key) { + for (_, 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] { @@ -2492,13 +2500,6 @@ impl Render for List { let affine = Affine::new((transform * item_transform).to_cols_array()); - 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 inf_rect = kurbo::Rect::from_origin_size(kurbo::Point::ZERO, kurbo::Size::new(f64::INFINITY, f64::INFINITY)); - scene.push_layer(peniko::Fill::NonZero, blending, opacity, kurbo::Affine::IDENTITY, &inf_rect); - } - FONT_CTX.with(|ctx| { let Ok(mut ctx) = ctx.try_borrow_mut() else { return }; let (font_ctx, layout_ctx) = &mut *ctx; @@ -2511,6 +2512,15 @@ impl Render for List { let mut layout = builder.build(text); layout.break_all_lines(None); + 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); + } + for line in layout.lines() { for item in line.items() { let PositionedLayoutItem::GlyphRun(glyph_run) = item else { continue }; @@ -2548,11 +2558,11 @@ impl Render for List { } } } - }); - if needs_layer { - scene.pop_layer(); - } + if needs_layer { + scene.pop_layer(); + } + }); } } fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option) { From 0007126074052ede075e2affe0c08ef15f0c828b Mon Sep 17 00:00:00 2001 From: Annonnymmousss Date: Mon, 25 May 2026 03:30:30 +0530 Subject: [PATCH 05/14] feat: Split text node to text_layer and text_to_vector node --- .../graph_modification_utils.rs | 54 ++++ node-graph/libraries/core-types/src/lib.rs | 3 +- node-graph/libraries/core-types/src/list.rs | 24 ++ .../libraries/rendering/src/renderer.rs | 259 +++++++++++++++--- node-graph/libraries/resources/src/lib.rs | 6 + node-graph/nodes/gstd/src/text.rs | 142 +++++++++- 6 files changed, 452 insertions(+), 36 deletions(-) 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 266564c713..7792dab9ed 100644 --- a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs +++ b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs @@ -458,6 +458,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)) } @@ -521,6 +525,56 @@ pub fn get_text<'a>( 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/node-graph/libraries/core-types/src/lib.rs b/node-graph/libraries/core-types/src/lib.rs index 0ad5bc23f2..f00e92839c 100644 --- a/node-graph/libraries/core-types/src/lib.rs +++ b/node-graph/libraries/core-types/src/lib.rs @@ -25,7 +25,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_FONT_FAMILY, ATTR_FONT_SIZE, 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 5bf72070b6..7575e7b726 100644 --- a/node-graph/libraries/core-types/src/list.rs +++ b/node-graph/libraries/core-types/src/list.rs @@ -86,9 +86,33 @@ pub const ATTR_STROKE: &str = "stroke"; /// 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 font `Resource`. Only set by `text_layer`; used by `text_to_vector` and the renderer 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. Only stored when it deviates from the default. +pub const ATTR_TEXT_ALIGN: &str = "text_align"; + // =========================== // Implicit attribute defaults // =========================== diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 3953f84d83..e8446089ea 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_FONT_FAMILY, ATTR_FONT_SIZE, 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}; @@ -27,7 +28,7 @@ use graphic_types::vector_types::vector::style::{Fill, PaintOrder, RenderMode, S use graphic_types::{Artboard, Graphic, Vector}; use kurbo::{Affine, BezPath, Cap, Join, Shape, StrokeOpts}; use num_traits::Zero; -use parley::{FontContext, FontFamily, FontStack, LayoutContext, PositionedLayoutItem, StyleProperty}; +use parley::{AlignmentOptions, FontContext, FontFamily, FontStack, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty}; use skrifa::GlyphId; use skrifa::MetadataProvider; use skrifa::instance::{LocationRef, NormalizedCoord, Size}; @@ -45,7 +46,7 @@ use vello::*; // Thread local storage for font bytes thread_local! { - static RENDER_FONTS: RefCell)]>> = RefCell::new(Arc::from([])); + static RENDER_FONTS: RefCell)]>> = RefCell::new(Arc::from([])); } // Thread-local parley font shaping context @@ -58,14 +59,19 @@ 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, u64, Arc<[u8]>)]> = fonts +pub fn set_render_fonts(fonts: impl IntoIterator)>) { + let slice: Arc<[(String, String, u64, Arc<[u8]>)]> = fonts .into_iter() - .map(|(name, bytes)| { + .map(|(family, style, bytes)| { let mut hasher = std::collections::hash_map::DefaultHasher::new(); bytes.hash(&mut hasher); - (name, hasher.finish(), bytes) + (family, style, hasher.finish(), bytes) }) .collect::>() .into(); @@ -2298,12 +2304,13 @@ struct SvgGlyphPen { d: String, ox: f64, oy: f64, + tilt_tan: f64, } impl SvgGlyphPen { #[inline] - fn px(&self, x: f32) -> f64 { - self.ox + x as f64 + fn px(&self, x: f32, y: f32) -> f64 { + self.ox + x as f64 + (y as f64 * self.tilt_tan) } #[inline] @@ -2314,16 +2321,16 @@ impl SvgGlyphPen { impl OutlinePen for SvgGlyphPen { fn move_to(&mut self, x: f32, y: f32) { - write!(self.d, "M {} {} ", self.px(x), self.py(y)).ok(); + 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), self.py(y)).ok(); + 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), self.py(y1), self.px(x), self.py(y)).ok(); + 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), self.py(y1), self.px(x2), self.py(y2), self.px(x), self.py(y)).ok(); + 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 "); @@ -2335,24 +2342,33 @@ 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.ox + x as f64, self.oy - y as f64)); + 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.ox + x as f64, self.oy - y as f64)); + 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.ox + cx as f64, self.oy - cy as f64), (self.ox + x as f64, self.oy - y as f64)); + 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.ox + cx1 as f64, self.oy - cy1 as f64), - (self.ox + cx2 as f64, self.oy - cy2 as f64), - (self.ox + x as f64, self.oy - y as f64), - ); + 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(); @@ -2364,7 +2380,7 @@ fn ensure_fonts_registered(font_ctx: &mut parley::FontContext) { REGISTERED_FONTS.with(|reg| { let mut reg = reg.borrow_mut(); RENDER_FONTS.with(|rf| { - for (_, hash, bytes) in rf.borrow().iter() { + for (family, style, hash, bytes) in rf.borrow().iter() { if reg.insert(*hash) { struct ArcBytes(std::sync::Arc<[u8]>); impl AsRef<[u8]> for ArcBytes { @@ -2373,7 +2389,15 @@ fn ensure_fonts_registered(font_ctx: &mut parley::FontContext) { } } let font_data: std::sync::Arc + Send + Sync> = std::sync::Arc::new(ArcBytes(bytes.clone())); - font_ctx.collection.register_fonts(parley::fontique::Blob::new(font_data), None); + 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()); + }); + } + } } } }); @@ -2381,7 +2405,7 @@ fn ensure_fonts_registered(font_ctx: &mut parley::FontContext) { } const DEFAULT_FONT_FAMILY: &str = "Lato"; -const DEFAULT_FONT_SIZE: f64 = 16.; +const DEFAULT_FONT_SIZE: f64 = 24.; impl Render for List { fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { @@ -2396,9 +2420,26 @@ impl Render for List { 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| { @@ -2410,14 +2451,63 @@ impl Render for List { 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::FontStack(FontStack::Single(FontFamily::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); - layout.break_all_lines(None); + 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(max_width_f32, 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(); + let mut run_x = glyph_run.offset() + x_offset; let run_y = glyph_run.baseline(); let run = glyph_run.run(); let font = run.font(); @@ -2428,6 +2518,12 @@ impl Render for List { 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; @@ -2436,9 +2532,14 @@ impl Render for List { 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)); - let mut pen = SvgGlyphPen { d: String::new(), ox, oy }; + + 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); + glyph_paths.push(pen.d.clone()); + } else if space_extra != 0. && glyph.advance > 0. { + run_x += space_extra; } } } @@ -2492,12 +2593,29 @@ impl Render for List { 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| { @@ -2509,8 +2627,23 @@ impl Render for List { 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::FontStack(FontStack::Single(FontFamily::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); - layout.break_all_lines(None); + 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(max_width_f32, parley_align, AlignmentOptions::default()); let needs_layer = opacity < 1. || blend_mode_attr != BlendMode::default(); if needs_layer { @@ -2521,11 +2654,45 @@ impl Render for List { 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(); + let mut run_x = glyph_run.offset() + x_offset; let run_y = glyph_run.baseline(); let run = glyph_run.run(); let font = run.font(); @@ -2536,6 +2703,7 @@ impl Render for List { 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; @@ -2545,8 +2713,13 @@ impl Render for List { let Some(outline) = outlines.get(glyph_id) else { continue }; let settings = DrawSettings::unhinted(Size::new(font_size_pts), LocationRef::new(&normalized_coords)); - let mut bez_path = BezPath::new(); - let mut pen = VelloPen { path: &mut bez_path, ox, oy }; + 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); @@ -2554,6 +2727,8 @@ impl Render for List { } 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; } } } @@ -2578,6 +2753,17 @@ impl Render for List { 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. @@ -2589,9 +2775,14 @@ impl Render for List { 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::FontStack(FontStack::Single(FontFamily::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(None); - Some((layout.width() as f64, layout.height() as f64)) + layout.break_all_lines(max_width.map(|w| w as f32)); + layout.align(max_width.map(|w| w as f32), 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)); diff --git a/node-graph/libraries/resources/src/lib.rs b/node-graph/libraries/resources/src/lib.rs index 516e186371..aebe68156f 100644 --- a/node-graph/libraries/resources/src/lib.rs +++ b/node-graph/libraries/resources/src/lib.rs @@ -32,6 +32,12 @@ impl Resource { } } +impl Default for Resource { + fn default() -> Self { + Self::empty() + } +} + impl From<&Resource> for Arc + Send + Sync> { fn from(val: &Resource) -> Self { val.inner.clone() diff --git a/node-graph/nodes/gstd/src/text.rs b/node-graph/nodes/gstd/src/text.rs index a7a24962c3..1fd87f87c5 100644 --- a/node-graph/nodes/gstd/src/text.rs +++ b/node-graph/nodes/gstd/src/text.rs @@ -1,5 +1,8 @@ -use core_types::Ctx; use core_types::list::List; +use core_types::{ + ATTR_EDITOR_LAYER_PATH, ATTR_FONT_SIZE, 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, Ctx, +}; use graph_craft::application_io::resource::Resource; use graphic_types::Vector; pub use text_nodes::*; @@ -73,3 +76,140 @@ fn text( to_path(&text, &font, 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 loaded font file used to render the text. The editor resolves the chosen typeface to these bytes via the resource system. + #[widget(ParsedWidgetOverride::Custom = "text_font")] + font: Resource, + /// 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 != Resource::default() { + list.set_attribute(ATTR_TEXT_FONT, 0, font); + } + 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( + _: impl Ctx, + /// A styled list of text strings produced by the **Text Layer** node (or any other `List` source). + #[implementations(List)] + strings: List, + /// 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: Resource = strings.attribute_cloned_or_default(ATTR_TEXT_FONT, index); + + 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, 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 +} From 0786aa68ecf35723d1454166746397829b2eae50 Mon Sep 17 00:00:00 2001 From: Annonnymmousss Date: Mon, 25 May 2026 03:50:54 +0530 Subject: [PATCH 06/14] fix: CI fail because of difference in nature of Mac and github action --- node-graph/libraries/rendering/src/renderer.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index e8446089ea..27876c1ba9 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -28,7 +28,7 @@ use graphic_types::vector_types::vector::style::{Fill, PaintOrder, RenderMode, S use graphic_types::{Artboard, Graphic, Vector}; use kurbo::{Affine, BezPath, Cap, Join, Shape, StrokeOpts}; use num_traits::Zero; -use parley::{AlignmentOptions, FontContext, FontFamily, FontStack, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty}; +use parley::{AlignmentOptions, FontContext, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty}; use skrifa::GlyphId; use skrifa::MetadataProvider; use skrifa::instance::{LocationRef, NormalizedCoord, Size}; @@ -2450,7 +2450,7 @@ impl Render for List { 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::FontStack(FontStack::Single(FontFamily::Named(Cow::Borrowed(font_family.as_str()))))); + builder.push_default(StyleProperty::FontStack(parley::style::FontStack::Single(parley::style::FontFamily::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)); @@ -2626,7 +2626,7 @@ impl Render for List { 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::FontStack(FontStack::Single(FontFamily::Named(Cow::Borrowed(font_family.as_str()))))); + builder.push_default(StyleProperty::FontStack(parley::style::FontStack::Single(parley::style::FontFamily::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)); @@ -2774,7 +2774,7 @@ impl Render for List { 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::FontStack(FontStack::Single(FontFamily::Named(Cow::Borrowed(font_family.as_str()))))); + builder.push_default(StyleProperty::FontStack(parley::style::FontStack::Single(parley::style::FontFamily::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); From 36c6b8178d57aef085bbf76e60b814d7835542cc Mon Sep 17 00:00:00 2001 From: Annonnymmousss Date: Mon, 25 May 2026 04:02:36 +0530 Subject: [PATCH 07/14] chore: fix --- node-graph/libraries/rendering/src/renderer.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 27876c1ba9..dddf0b4137 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -2450,7 +2450,7 @@ impl Render for List { 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::FontStack(parley::style::FontStack::Single(parley::style::FontFamily::Named(Cow::Borrowed(font_family.as_str()))))); + builder.push_default(StyleProperty::FontStack(parley::FontStack::Single(parley::FontFamily::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)); @@ -2626,7 +2626,7 @@ impl Render for List { 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::FontStack(parley::style::FontStack::Single(parley::style::FontFamily::Named(Cow::Borrowed(font_family.as_str()))))); + builder.push_default(StyleProperty::FontStack(parley::FontStack::Single(parley::FontFamily::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)); @@ -2774,7 +2774,7 @@ impl Render for List { 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::FontStack(parley::style::FontStack::Single(parley::style::FontFamily::Named(Cow::Borrowed(font_family.as_str()))))); + builder.push_default(StyleProperty::FontStack(parley::FontStack::Single(parley::FontFamily::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); From 146fb61fb1bac20f98f6a7cd5a57461842ec6734 Mon Sep 17 00:00:00 2001 From: Annonnymmousss Date: Mon, 25 May 2026 04:30:28 +0530 Subject: [PATCH 08/14] chore: replace FontStack as it got removed in parley 0.9 --- node-graph/libraries/rendering/src/renderer.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index dddf0b4137..a6768ebbf9 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -2450,7 +2450,7 @@ impl Render for List { 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::FontStack(parley::FontStack::Single(parley::FontFamily::Named(Cow::Borrowed(font_family.as_str()))))); + 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)); @@ -2467,7 +2467,7 @@ impl Render for List { 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(max_width_f32, parley_align, AlignmentOptions::default()); + layout.align(parley_align, AlignmentOptions::default()); let tilt_tan = tilt.to_radians().tan(); @@ -2626,7 +2626,7 @@ impl Render for List { 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::FontStack(parley::FontStack::Single(parley::FontFamily::Named(Cow::Borrowed(font_family.as_str()))))); + 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)); @@ -2643,7 +2643,7 @@ impl Render for List { 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(max_width_f32, parley_align, AlignmentOptions::default()); + layout.align(parley_align, AlignmentOptions::default()); let needs_layer = opacity < 1. || blend_mode_attr != BlendMode::default(); if needs_layer { @@ -2774,12 +2774,12 @@ impl Render for List { 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::FontStack(parley::FontStack::Single(parley::FontFamily::Named(Cow::Borrowed(font_family.as_str()))))); + 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(max_width.map(|w| w as f32), parley_align, AlignmentOptions::default()); + 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)) From a07d99e6152717e032b6dbc3c60565b5a13f0cdb Mon Sep 17 00:00:00 2001 From: Annonnymmousss Date: Mon, 25 May 2026 04:31:23 +0530 Subject: [PATCH 09/14] chore: fmt --- node-graph/libraries/rendering/src/renderer.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index a6768ebbf9..898146fd55 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -2450,7 +2450,9 @@ impl Render for List { 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::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)); @@ -2626,7 +2628,9 @@ impl Render for List { 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::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)); @@ -2774,7 +2778,9 @@ impl Render for List { 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::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); From 4e2b7fd40e3674da91f9c7a90e936da66ae00cb0 Mon Sep 17 00:00:00 2001 From: Annonnymmousss Date: Sat, 6 Jun 2026 03:11:49 +0530 Subject: [PATCH 10/14] chore: migrate the rendering as of new resource architechture --- Cargo.lock | 2 + .../graph_modification_utils.rs | 14 +- editor/src/node_graph_executor/runtime.rs | 7 + node-graph/libraries/core-types/src/lib.rs | 4 +- node-graph/libraries/core-types/src/list.rs | 8 +- .../core-types/src/render_complexity.rs | 2 +- .../libraries/graphic-types/src/graphic.rs | 4 +- node-graph/libraries/rendering/Cargo.toml | 2 + .../libraries/rendering/src/renderer.rs | 218 ++++-------------- node-graph/nodes/blending/src/lib.rs | 23 ++ node-graph/nodes/graphic/src/artboard.rs | 1 + node-graph/nodes/graphic/src/graphic.rs | 13 +- node-graph/nodes/gstd/src/text.rs | 27 ++- node-graph/nodes/text/src/text_context.rs | 2 +- .../nodes/transform/src/transform_nodes.rs | 1 + 15 files changed, 130 insertions(+), 198 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d39ac335e2..85fd337c97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4593,6 +4593,7 @@ dependencies = [ "dyn-any", "glam", "graphene-hash", + "graphene-resource", "graphic-types", "kurbo", "log", @@ -4600,6 +4601,7 @@ dependencies = [ "parley", "serde", "skrifa", + "text-nodes", "usvg", "vector-types", "vello", 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 7792dab9ed..157d90593f 100644 --- a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs +++ b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs @@ -525,15 +525,21 @@ pub fn get_text<'a>( 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)> { +/// Gets properties from the Text Layer node. Resolves the font selection by reading the resource id and lookup via the fonts message handler. +pub fn get_text_layer<'a>( + layer: LayerNodeIdentifier, + network_interface: &'a NodeNetworkInterface, + fonts: &FontsMessageHandler, + resources: &ResourceMessageHandler, +) -> Option<(&'a 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 font = match &inputs[graphene_std::text::text_layer::FontInput::INDEX].as_value() { + Some(TaggedValue::Resource(resource_id)) => fonts.id_font(resources, *resource_id).unwrap_or_default(), + _ => Font::default(), }; let Some(&TaggedValue::F64(font_size)) = inputs[graphene_std::text::text_layer::SizeInput::INDEX].as_value() else { return None; diff --git a/editor/src/node_graph_executor/runtime.rs b/editor/src/node_graph_executor/runtime.rs index ea6cbb3ff1..4121bb3d6a 100644 --- a/editor/src/node_graph_executor/runtime.rs +++ b/editor/src/node_graph_executor/runtime.rs @@ -434,6 +434,13 @@ impl NodeRuntime { // Insert the vector modify self.vector_modify.insert(parent_network_node_id, io.output.element(0).cloned().unwrap_or_default()); } + // String list: thumbnail + else if let Some(io) = introspected_data.downcast_ref::>>() { + if update_thumbnails { + let bounds = io.output.thumbnail_bounding_box(DAffine2::IDENTITY, true); + Self::render_thumbnail(&mut self.thumbnail_renders, parent_network_node_id, &io.output, bounds, responses) + } + } // Other else { log::warn!("Failed to downcast monitor node output {parent_network_node_id:?}"); diff --git a/node-graph/libraries/core-types/src/lib.rs b/node-graph/libraries/core-types/src/lib.rs index f00e92839c..981300a321 100644 --- a/node-graph/libraries/core-types/src/lib.rs +++ b/node-graph/libraries/core-types/src/lib.rs @@ -25,8 +25,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_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, + ATTR_FONT_SIZE, 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 7575e7b726..b7d13f48ab 100644 --- a/node-graph/libraries/core-types/src/list.rs +++ b/node-graph/libraries/core-types/src/list.rs @@ -83,13 +83,7 @@ pub const ATTR_FILL: &str = "fill"; /// Vector graphics object's stroke paint, of type List where T is any graphic type. pub const ATTR_STROKE: &str = "stroke"; -/// 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.`). +/// Text item's font size in document-space units (`f64`, implicit default `24.`). pub const ATTR_FONT_SIZE: &str = "font_size"; /// Text item's font `Resource`. Only set by `text_layer`; used by `text_to_vector` and the renderer to reconstruct exact glyph paths. diff --git a/node-graph/libraries/core-types/src/render_complexity.rs b/node-graph/libraries/core-types/src/render_complexity.rs index 15578d771c..691c644aa4 100644 --- a/node-graph/libraries/core-types/src/render_complexity.rs +++ b/node-graph/libraries/core-types/src/render_complexity.rs @@ -22,6 +22,6 @@ impl RenderComplexity for Color { impl RenderComplexity for String { fn render_complexity(&self) -> usize { - 1 + self.chars().count() } } diff --git a/node-graph/libraries/graphic-types/src/graphic.rs b/node-graph/libraries/graphic-types/src/graphic.rs index d5bf9205cd..70a0a376a9 100644 --- a/node-graph/libraries/graphic-types/src/graphic.rs +++ b/node-graph/libraries/graphic-types/src/graphic.rs @@ -576,7 +576,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, + Graphic::Text(list) => list.bounding_box(transform, include_stroke), } } @@ -588,7 +588,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, + Graphic::Text(list) => list.thumbnail_bounding_box(transform, include_stroke), } } } diff --git a/node-graph/libraries/rendering/Cargo.toml b/node-graph/libraries/rendering/Cargo.toml index c8e4375e3c..13facc359c 100644 --- a/node-graph/libraries/rendering/Cargo.toml +++ b/node-graph/libraries/rendering/Cargo.toml @@ -15,6 +15,8 @@ serde = ["dep:serde", "core-types/serde", "vector-types/serde", "graphic-types/s dyn-any = { workspace = true } core-types = { workspace = true } graphene-hash = { workspace = true } +graphene-resource = { workspace = true } +text-nodes = { workspace = true } # Workspace dependencies glam = { workspace = true } diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 898146fd55..03e97e8f1d 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -13,12 +13,13 @@ 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_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, + ATTR_FONT_SIZE, ATTR_GRADIENT_TYPE, ATTR_LOCATION, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_SPREAD_METHOD, 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 dyn_any::DynAny; use glam::{DAffine2, DVec2}; use graphene_hash::CacheHashWrapper; +use graphene_resource::Resource; use graphic_types::graphic::{fill_graphic_list_at, graphic_list_at, is_stroke_fully_transparent_at, stroke_graphic_list_at}; use graphic_types::raster_types::{BitmapMut, CPU, GPU, Image, Raster}; use graphic_types::vector_types::gradient::{GradientStops, GradientType}; @@ -28,56 +29,20 @@ use graphic_types::vector_types::vector::style::{Fill, PaintOrder, RenderMode, S use graphic_types::{Artboard, Graphic, Vector}; use kurbo::{Affine, BezPath, Cap, Join, Shape, StrokeOpts}; use num_traits::Zero; -use parley::{AlignmentOptions, FontContext, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty}; +use parley::PositionedLayoutItem; 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::hash::Hash; 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 { @@ -2375,36 +2340,6 @@ impl OutlinePen for VelloPen<'_> { } } -/// 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 { @@ -2419,58 +2354,33 @@ impl Render for List { 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: Resource = self.attribute_cloned_or_default(ATTR_TEXT_FONT, index); 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 align: text_nodes::TextAlign = self.attribute_cloned_or_default(ATTR_TEXT_ALIGN, index); 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 typesetting = text_nodes::TypesettingConfig { + font_size, + line_height_ratio: line_height, + character_spacing: char_spacing, + max_width, + max_height, + align, + tilt, }; 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); + text_nodes::TextContext::with_thread_local(|ctx| { + let Some(layout) = ctx.layout_text(text, &font, typesetting) else { return }; 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 last_line_correction = align.last_line_correction(); let tilt_tan = tilt.to_radians().tan(); for line in layout.lines() { @@ -2594,60 +2504,36 @@ impl Render for List { } 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: Resource = self.attribute_cloned_or_default(ATTR_TEXT_FONT, index); 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 align: text_nodes::TextAlign = self.attribute_cloned_or_default(ATTR_TEXT_ALIGN, index); 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 typesetting = text_nodes::TypesettingConfig { + font_size, + line_height_ratio: line_height, + character_spacing: char_spacing, + max_width, + max_height, + align, + tilt, }; 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); + text_nodes::TextContext::with_thread_local(|ctx| { + let Some(layout) = ctx.layout_text(text, &font, typesetting) else { return }; 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 last_line_correction = align.last_line_correction(); let needs_layer = opacity < 1. || blend_mode_attr != BlendMode::default(); if needs_layer { @@ -2755,42 +2641,34 @@ impl Render for List { 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: Resource = self.attribute_cloned_or_default(ATTR_TEXT_FONT, index); 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 align: text_nodes::TextAlign = self.attribute_cloned_or_default(ATTR_TEXT_ALIGN, index); let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); + let typesetting = text_nodes::TypesettingConfig { + font_size, + line_height_ratio: line_height, + character_spacing: char_spacing, + max_width, + max_height, + align, + tilt: 0., + }; + // 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 (width, height) = text_nodes::TextContext::with_thread_local(|ctx| { + ctx.layout_text(text, &font, typesetting).map(|layout| { 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)) + (w, h) }) - .unwrap_or((font_size, font_size)); + }) + .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.); diff --git a/node-graph/nodes/blending/src/lib.rs b/node-graph/nodes/blending/src/lib.rs index b81d32ec94..fea3aa5ad9 100644 --- a/node-graph/nodes/blending/src/lib.rs +++ b/node-graph/nodes/blending/src/lib.rs @@ -53,6 +53,11 @@ impl MultiplyAlpha for List { multiply_list_attribute(self, ATTR_OPACITY, factor); } } +impl MultiplyAlpha for List { + fn multiply_alpha(&mut self, factor: f64) { + multiply_list_attribute(self, ATTR_OPACITY, factor); + } +} pub(crate) trait MultiplyFill { fn multiply_fill(&mut self, factor: f64); @@ -87,6 +92,11 @@ impl MultiplyFill for List { multiply_list_attribute(self, ATTR_OPACITY_FILL, factor); } } +impl MultiplyFill for List { + fn multiply_fill(&mut self, factor: f64) { + multiply_list_attribute(self, ATTR_OPACITY_FILL, factor); + } +} trait SetBlendMode { fn set_blend_mode(&mut self, blend_mode: BlendMode); @@ -123,6 +133,11 @@ impl SetBlendMode for List { set_list_blend_mode(self, blend_mode); } } +impl SetBlendMode for List { + fn set_blend_mode(&mut self, blend_mode: BlendMode) { + set_list_blend_mode(self, blend_mode); + } +} trait SetClip { fn set_clip(&mut self, clip: bool); @@ -159,6 +174,11 @@ impl SetClip for List { set_list_clip(self, clip); } } +impl SetClip for List { + fn set_clip(&mut self, clip: bool) { + set_list_clip(self, clip); + } +} /// Applies the blend mode to the input graphics. Setting this allows for customizing how overlapping content is composited together. #[node_macro::node(category("Blending"))] @@ -171,6 +191,7 @@ fn blend_mode( List>, List, List, + List, )] mut content: T, /// The choice of equation that controls how brightness and color blends between overlapping pixels. @@ -194,6 +215,7 @@ fn opacity( List>, List, List, + List, )] mut content: T, /// Whether the *Opacity* property is enabled, multiplying the existing opacity by the chosen percentage. @@ -235,6 +257,7 @@ fn clipping_mask( List>, List, List, + List, )] mut content: T, /// Whether the content inherits the alpha of the content beneath it. diff --git a/node-graph/nodes/graphic/src/artboard.rs b/node-graph/nodes/graphic/src/artboard.rs index 1271372ecb..1a992c6656 100644 --- a/node-graph/nodes/graphic/src/artboard.rs +++ b/node-graph/nodes/graphic/src/artboard.rs @@ -15,6 +15,7 @@ pub async fn create_artboard( #[implementations( Context -> List, Context -> List, + Context -> List, Context -> List>, Context -> List>, Context -> List, diff --git a/node-graph/nodes/graphic/src/graphic.rs b/node-graph/nodes/graphic/src/graphic.rs index 17c64f8266..2e942d7471 100644 --- a/node-graph/nodes/graphic/src/graphic.rs +++ b/node-graph/nodes/graphic/src/graphic.rs @@ -116,6 +116,7 @@ async fn map( List>, List, List, + List, )] content: List, #[implementations( @@ -124,6 +125,7 @@ async fn map( Context -> List>, Context -> List, Context -> List, + Context -> List, )] mapped: impl Node, Output = List>, ) -> List { @@ -146,6 +148,7 @@ async fn mirror( #[implementations( List, List, + List, List>, List, List, @@ -495,11 +498,11 @@ fn read_attribute_raster( pub async fn extend( _: impl Ctx, /// The `List` whose items will appear at the start of the extended `List`. - #[implementations(List, List, List, List>, List>, List, List)] + #[implementations(List, List, List, List, List>, List>, List, List)] base: List, /// The `List` whose items will appear at the end of the extended `List`. #[expose] - #[implementations(List, List, List, List>, List>, List, List)] + #[implementations(List, List, List, List, List>, List>, List, List)] new: List, ) -> List { let mut base = base; @@ -514,9 +517,9 @@ pub async fn extend( #[node_macro::node(category(""))] pub async fn legacy_layer_extend( _: impl Ctx, - #[implementations(List, List, List, List>, List>, List, List)] base: List, + #[implementations(List, List, List, List, List>, List>, List, List)] base: List, #[expose] - #[implementations(List, List, List, List>, List>, List, List)] + #[implementations(List, List, List, List, List>, List>, List, List)] new: List, nested_node_path: List, ) -> List { @@ -548,6 +551,7 @@ pub async fn wrap_graphic + 'n>( List>, List, List, + List, DAffine2, )] content: T, @@ -567,6 +571,7 @@ pub async fn to_graphic( List>, List, List, + List, )] content: T, ) -> List { diff --git a/node-graph/nodes/gstd/src/text.rs b/node-graph/nodes/gstd/src/text.rs index 1fd87f87c5..35fc2eea3b 100644 --- a/node-graph/nodes/gstd/src/text.rs +++ b/node-graph/nodes/gstd/src/text.rs @@ -1,12 +1,16 @@ +use core_types::blending::BlendMode; use core_types::list::List; use core_types::{ - ATTR_EDITOR_LAYER_PATH, ATTR_FONT_SIZE, 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, Ctx, + ATTR_BLEND_MODE, ATTR_EDITOR_LAYER_PATH, ATTR_FONT_SIZE, ATTR_OPACITY, ATTR_OPACITY_FILL, 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, Ctx, }; use graph_craft::application_io::resource::Resource; use graphic_types::Vector; pub use text_nodes::*; +const DEFAULT_FONT_SIZE: f64 = 24.; +const DEFAULT_LINE_HEIGHT: f64 = 1.2; + /// Draws a text string as vector geometry with a choice of font and styling. #[node_macro::node(category("Text"))] fn text( @@ -129,9 +133,6 @@ fn text_layer( #[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. @@ -186,8 +187,8 @@ fn text_to_vector( let font: Resource = strings.attribute_cloned_or_default(ATTR_TEXT_FONT, index); 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), + font_size: strings.attribute_cloned_or(ATTR_FONT_SIZE, index, DEFAULT_FONT_SIZE), + line_height_ratio: strings.attribute_cloned_or(ATTR_TEXT_LINE_HEIGHT, index, DEFAULT_LINE_HEIGHT), 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), @@ -198,6 +199,9 @@ fn text_to_vector( let vectors = to_path(text, &font, 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); + let blend_mode = strings.attribute::(ATTR_BLEND_MODE, index).copied(); + let opacity = strings.attribute::(ATTR_OPACITY, index).copied(); + let opacity_fill = strings.attribute::(ATTR_OPACITY_FILL, index).copied(); for mut item in vectors.into_iter() { if transform != glam::DAffine2::IDENTITY { @@ -207,6 +211,15 @@ fn text_to_vector( if !layer_path.is_empty() { item.set_attribute(ATTR_EDITOR_LAYER_PATH, layer_path.clone()); } + if let Some(blend_mode) = blend_mode { + item.set_attribute(ATTR_BLEND_MODE, blend_mode); + } + if let Some(opacity) = opacity { + item.set_attribute(ATTR_OPACITY, opacity); + } + if let Some(opacity_fill) = opacity_fill { + item.set_attribute(ATTR_OPACITY_FILL, opacity_fill); + } result.push(item); } } diff --git a/node-graph/nodes/text/src/text_context.rs b/node-graph/nodes/text/src/text_context.rs index e5944245d4..0a133a1754 100644 --- a/node-graph/nodes/text/src/text_context.rs +++ b/node-graph/nodes/text/src/text_context.rs @@ -54,7 +54,7 @@ impl TextContext { } /// Create a text layout from the given font resource and typesetting configuration. - fn layout_text(&mut self, text: &str, font: &Resource, typesetting: TypesettingConfig) -> Option> { + pub fn layout_text(&mut self, text: &str, font: &Resource, typesetting: TypesettingConfig) -> Option> { let (font_family, font_info) = self.get_font_info(font)?; const DISPLAY_SCALE: f32 = 1.; diff --git a/node-graph/nodes/transform/src/transform_nodes.rs b/node-graph/nodes/transform/src/transform_nodes.rs index bf5b2f6488..8ed04f2cfe 100644 --- a/node-graph/nodes/transform/src/transform_nodes.rs +++ b/node-graph/nodes/transform/src/transform_nodes.rs @@ -17,6 +17,7 @@ async fn transform( Context -> DAffine2, Context -> DVec2, Context -> List, + Context -> List, Context -> List, Context -> List>, Context -> List>, From 7c91b6adc07c8f7a749da40ecd5aaef34978aebb Mon Sep 17 00:00:00 2001 From: Annonnymmousss Date: Sat, 6 Jun 2026 03:12:41 +0530 Subject: [PATCH 11/14] chore: add text_layer node to text tool for testing --- .../document/graph_operation/utility_types.rs | 12 ++------ .../common_functionality/utility_functions.rs | 4 +-- .../messages/tool/tool_messages/text_tool.rs | 30 +++++++++---------- 3 files changed, 19 insertions(+), 27 deletions(-) diff --git a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs index 84774cf0f2..8b01149daf 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -251,8 +251,8 @@ 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") + let text = resolve_proto_node_type(graphene_std::text::text_layer::IDENTIFIER) + .expect("Text Layer node does not exist") .node_template_input_override([ Some(NodeInput::value(TaggedValue::None, false)), Some(NodeInput::value(TaggedValue::String(text), false)), @@ -266,14 +266,10 @@ 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") .default_node_template(); - let fill = resolve_proto_node_type(graphene_std::vector_nodes::fill::IDENTIFIER) - .expect("Fill node does not exist") - .default_node_template(); let text_id = NodeId::new(); self.network_interface.insert_node(text_id, text, &[]); @@ -284,10 +280,6 @@ impl<'a> ModifyInputsContext<'a> { 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); - - let fill_id = NodeId::new(); - self.network_interface.insert_node(fill_id, fill, &[]); - self.network_interface.move_node_to_chain_start(&fill_id, layer, &[], self.import); } pub fn insert_color_value(&mut self, color: Color, layer: LayerNodeIdentifier) { diff --git a/editor/src/messages/tool/common_functionality/utility_functions.rs b/editor/src/messages/tool/common_functionality/utility_functions.rs index fb19f4d2b9..e98e25ea01 100644 --- a/editor/src/messages/tool/common_functionality/utility_functions.rs +++ b/editor/src/messages/tool/common_functionality/utility_functions.rs @@ -6,7 +6,7 @@ use crate::messages::portfolio::document::utility_types::document_metadata::Laye use crate::messages::portfolio::document::utility_types::network_interface::{NodeNetworkInterface, OutputConnector}; use crate::messages::portfolio::document::utility_types::transformation::Selected; use crate::messages::prelude::*; -use crate::messages::tool::common_functionality::graph_modification_utils::{NodeGraphLayer, get_text}; +use crate::messages::tool::common_functionality::graph_modification_utils::{NodeGraphLayer, get_text_layer}; use crate::messages::tool::common_functionality::transformation_cage::SelectedEdges; use crate::messages::tool::tool_messages::path_tool::PathOverlayMode; use crate::messages::tool::utility_types::ToolType; @@ -69,7 +69,7 @@ pub fn text_bounding_box(layer: LayerNodeIdentifier, document: &DocumentMessageH } // Fallback: recompute from text content (e.g. layer hasn't rendered yet) - let Some((text, font, typesetting, _)) = get_text(layer, &document.network_interface, fonts, &document.resources) else { + let Some((text, font, typesetting)) = get_text_layer(layer, &document.network_interface, fonts, &document.resources) else { return Quad::from_box([DVec2::ZERO, DVec2::ZERO]); }; let font = fonts.get_resource_or_queue_load(&font, responses); diff --git a/editor/src/messages/tool/tool_messages/text_tool.rs b/editor/src/messages/tool/tool_messages/text_tool.rs index 9e4c4700c2..c0fc865463 100644 --- a/editor/src/messages/tool/tool_messages/text_tool.rs +++ b/editor/src/messages/tool/tool_messages/text_tool.rs @@ -24,7 +24,7 @@ use graphene_std::choice_type::ChoiceTypeStatic; use graphene_std::color::SRGBA8; use graphene_std::renderer::Quad; use graphene_std::text::{Font, TextAlign, TypesettingConfig, lines_clipping}; -use graphene_std::vector::style::{Fill, FillChoice, FillChoiceUI}; +use graphene_std::vector::style::{FillChoice, FillChoiceUI}; use graphene_std::{Color, NodeInputDecleration}; #[derive(Default, ExtractField)] @@ -106,7 +106,7 @@ impl ToolMetadata for TextTool { } fn create_text_widgets(tool: &TextTool, font_catalog: &FontCatalog, document: &DocumentMessageHandler) -> Vec { - let text_node_id = can_edit_selected(document).and_then(|layer| graph_modification_utils::get_text_id(layer, &document.network_interface)); + let text_node_id = can_edit_selected(document).and_then(|layer| graph_modification_utils::get_text_layer_id(layer, &document.network_interface)); let apply_font = move |font: Font| -> Message { match text_node_id { @@ -298,7 +298,7 @@ impl<'a> MessageHandler> for Text ToolMessage::Text(TextToolMessage::UpdateOptions { options }) => options, ToolMessage::Text(TextToolMessage::SelectionChanged) => { if let Some(layer) = can_edit_selected(context.document) - && let Some((_, font, typesetting, _)) = graph_modification_utils::get_text(layer, &context.document.network_interface, context.fonts, &context.document.resources) + && let Some((_, font, typesetting)) = graph_modification_utils::get_text_layer(layer, &context.document.network_interface, context.fonts, &context.document.resources) { self.options.align = typesetting.align; self.options.font_size = typesetting.font_size; @@ -344,7 +344,7 @@ impl<'a> MessageHandler> for Text editing_text.typesetting.font_size = font_size; } if let Some(layer) = can_edit_selected(context.document) - && let Some(node_id) = graph_modification_utils::get_text_id(layer, &context.document.network_interface) + && let Some(node_id) = graph_modification_utils::get_text_layer_id(layer, &context.document.network_interface) { responses.add(NodeGraphMessage::SetInputValue { node_id, @@ -359,7 +359,7 @@ impl<'a> MessageHandler> for Text editing_text.typesetting.align = align; } if let Some(layer) = can_edit_selected(context.document) - && let Some(node_id) = graph_modification_utils::get_text_id(layer, &context.document.network_interface) + && let Some(node_id) = graph_modification_utils::get_text_layer_id(layer, &context.document.network_interface) { responses.add(NodeGraphMessage::SetInputValue { node_id, @@ -516,7 +516,7 @@ impl TextToolData { fn load_layer_text_node(&mut self, document: &DocumentMessageHandler, fonts: &FontsMessageHandler) -> Option<()> { let transform = document.metadata().transform_to_viewport(self.layer); let color = graph_modification_utils::get_fill_color(self.layer, &document.network_interface).unwrap_or(Color::BLACK); - let (text, font, typesetting, _) = graph_modification_utils::get_text(self.layer, &document.network_interface, fonts, &document.resources)?; + let (text, font, typesetting) = graph_modification_utils::get_text_layer(self.layer, &document.network_interface, fonts, &document.resources)?; self.editing_text = Some(EditingText { text: text.clone(), font, @@ -546,7 +546,7 @@ impl TextToolData { responses.add(NodeGraphMessage::SelectedNodesSet { nodes: vec![self.layer.to_node()] }); // Make the rendered text invisible while editing responses.add(NodeGraphMessage::SetInput { - input_connector: InputConnector::node(graph_modification_utils::get_text_id(self.layer, &document.network_interface).unwrap(), 1), + input_connector: InputConnector::node(graph_modification_utils::get_text_layer_id(self.layer, &document.network_interface).unwrap(), 1), input: NodeInput::value(TaggedValue::String("".to_string()), false), }); responses.add(NodeGraphMessage::RunDocumentGraph); @@ -571,10 +571,10 @@ impl TextToolData { parent: document.new_layer_parent(true), insert_index: 0, }); - responses.add(GraphOperationMessage::FillSet { - layer: self.layer, - fill: if let Some(color) = editing_text.color { Fill::Solid(color) } else { Fill::None }, - }); + // responses.add(GraphOperationMessage::FillSet { + // layer: self.layer, + // fill: if let Some(color) = editing_text.color { Fill::Solid(color) } else { Fill::None }, + // }); let transform = editing_text.transform; self.editing_text = Some(editing_text); @@ -631,7 +631,7 @@ fn can_edit_selected(document: &DocumentMessageHandler) -> Option Date: Sat, 6 Jun 2026 05:45:00 +0530 Subject: [PATCH 12/14] code review --- .../graph_modification_utils.rs | 22 +++++++++---------- .../libraries/graphic-types/src/graphic.rs | 7 +++--- .../libraries/rendering/src/render_ext.rs | 2 +- .../libraries/rendering/src/renderer.rs | 6 ++--- node-graph/nodes/graphic/src/graphic.rs | 2 +- node-graph/nodes/path-bool/src/lib.rs | 1 + 6 files changed, 21 insertions(+), 19 deletions(-) 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 157d90593f..af273f6f92 100644 --- a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs +++ b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs @@ -534,38 +534,38 @@ pub fn get_text_layer<'a>( ) -> Option<(&'a 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 { + let Some(TaggedValue::String(text)) = inputs.get(graphene_std::text::text_layer::TextInput::INDEX)?.as_value() else { return None; }; - let font = match &inputs[graphene_std::text::text_layer::FontInput::INDEX].as_value() { + let font = match inputs.get(graphene_std::text::text_layer::FontInput::INDEX)?.as_value() { Some(TaggedValue::Resource(resource_id)) => fonts.id_font(resources, *resource_id).unwrap_or_default(), _ => Font::default(), }; - let Some(&TaggedValue::F64(font_size)) = inputs[graphene_std::text::text_layer::SizeInput::INDEX].as_value() else { + let Some(&TaggedValue::F64(font_size)) = inputs.get(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 { + let Some(&TaggedValue::F64(line_height_ratio)) = inputs.get(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 { + let Some(&TaggedValue::F64(character_spacing)) = inputs.get(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 { + let Some(&TaggedValue::Bool(has_max_width)) = inputs.get(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 { + let Some(&TaggedValue::F64(max_width)) = inputs.get(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 { + let Some(&TaggedValue::Bool(has_max_height)) = inputs.get(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 { + let Some(&TaggedValue::F64(max_height)) = inputs.get(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 { + let Some(&TaggedValue::F64(tilt)) = inputs.get(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 { + let Some(&TaggedValue::TextAlign(align)) = inputs.get(graphene_std::text::text_layer::AlignInput::INDEX)?.as_value() else { return None; }; diff --git a/node-graph/libraries/graphic-types/src/graphic.rs b/node-graph/libraries/graphic-types/src/graphic.rs index 70a0a376a9..f007a07e6f 100644 --- a/node-graph/libraries/graphic-types/src/graphic.rs +++ b/node-graph/libraries/graphic-types/src/graphic.rs @@ -524,7 +524,7 @@ impl Graphic { } Graphic::Color(list) => list.element(0).is_some_and(|color| color.is_opaque()), Graphic::Gradient(list) => list.element(0).is_some_and(|stops| stops.iter().all(|stop| stop.color.is_opaque())), - Graphic::RasterCPU(_) | Graphic::RasterGPU(_) => false, + Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Text(_) => false, } } @@ -544,7 +544,7 @@ impl Graphic { }), Graphic::Color(list) => list.iter_element_values().all(|color| color.a() == 0.), Graphic::Gradient(list) => list.iter_element_values().all(|stops| stops.iter().all(|stop| stop.color.a() == 0.)), - Graphic::RasterCPU(_) | Graphic::RasterGPU(_) => false, + Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Text(_) => false, } } @@ -563,6 +563,7 @@ impl Graphic { Graphic::Gradient(list) => list.is_empty(), Graphic::RasterCPU(list) => list.is_empty(), Graphic::RasterGPU(list) => list.is_empty(), + Graphic::Text(list) => list.is_empty(), } } } @@ -618,7 +619,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(), + Self::Text(list) => list.render_complexity(), } } } diff --git a/node-graph/libraries/rendering/src/render_ext.rs b/node-graph/libraries/rendering/src/render_ext.rs index 197887add0..8886c71d82 100644 --- a/node-graph/libraries/rendering/src/render_ext.rs +++ b/node-graph/libraries/rendering/src/render_ext.rs @@ -244,7 +244,7 @@ impl RenderExt for List { let gradient_id = gradient_list.render(svg_defs, item_transform, element_transform, stroke_transform, bounds, transformed_bounds, render_params, target); format!(r##" {paint_attr}="url(#{gradient_id})""##) } - Some(Graphic::Vector(_)) | Some(Graphic::RasterCPU(_)) | Some(Graphic::RasterGPU(_)) | Some(Graphic::Graphic(_)) => { + Some(Graphic::Vector(_)) | Some(Graphic::RasterCPU(_)) | Some(Graphic::RasterGPU(_)) | Some(Graphic::Graphic(_)) | Some(Graphic::Text(_)) => { let bounds = if target == PaintTarget::Stroke { // To prevent a wraparound artefact occurring when the tile boundary and the stroke region are perfectly aligned, the local coordinate is expanded slightly. let inverse = |len: f64| if len > 0. { 1. / len } else { 0. }; diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 03e97e8f1d..0527a51e5b 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -1405,7 +1405,7 @@ impl Render for List { let brush_transform = kurbo::Affine::new((inverse_element_transform * parent_transform).to_cols_array()); scene.fill(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), &brush, Some(brush_transform), path); } - Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_) => { + Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_) | Graphic::Text(_) => { scene.push_clip_layer(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), path); paint.render_to_vello(scene, multiplied_transform, context, render_params); scene.pop_layer(); @@ -1487,7 +1487,7 @@ impl Render for List { scene.stroke(&stroke, kurbo::Affine::new(element_transform.to_cols_array()), &brush, Some(brush_transform), &path); } - Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_) => { + Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_) | Graphic::Text(_) => { let stroked = peniko::kurbo::stroke(path.iter(), &stroke, &StrokeOpts::default(), 0.01); scene.push_clip_layer(peniko::Fill::NonZero, kurbo::Affine::new(element_transform.to_cols_array()), &stroked); @@ -2539,7 +2539,7 @@ impl Render for List { 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 bounds = kurbo::Rect::new(-padding, -padding, alignment_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); } diff --git a/node-graph/nodes/graphic/src/graphic.rs b/node-graph/nodes/graphic/src/graphic.rs index 2e942d7471..cf1b3c06a0 100644 --- a/node-graph/nodes/graphic/src/graphic.rs +++ b/node-graph/nodes/graphic/src/graphic.rs @@ -600,7 +600,7 @@ pub async fn flatten_graphic(_: impl Ctx, content: List, fully_flatten: flatten_list(output_graphic_list, current_element, fully_flatten, recursion_depth + 1); } - // Push any leaf elements we encounter: either `Graphic::Graphic(...)` values beyond the recursion depth, or non-`Graphic::Graphic` variants (e.g. `Graphic::Vector`, `Graphic::Raster*`, `Graphic::Color`, `Graphic::Gradient`) + // Push any leaf elements we encounter: either `Graphic::Graphic(...)` values beyond the recursion depth, or non-`Graphic::Graphic` variants (e.g. `Graphic::Vector`, `Graphic::Raster*`, `Graphic::Color`, `Graphic::Gradient`, `Graphic::Text`) _ => { let attributes = current_graphic_list.clone_item_attributes(index); output_graphic_list.push(Item::from_parts(current_element, attributes)); diff --git a/node-graph/nodes/path-bool/src/lib.rs b/node-graph/nodes/path-bool/src/lib.rs index 9fffc0a704..bce701c3bf 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::>(), + // Text carries no vector geometry until shaped by the 'Text to Vector' node, whose font shaping this crate intentionally doesn't depend on Graphic::Text(_) => Vec::new(), } }) From 834308a76a27dcae479a89182f57100ffe0a2fa9 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Thu, 11 Jun 2026 16:48:23 -0700 Subject: [PATCH 13/14] Make boolean ops support the Text type --- Cargo.lock | 1 + node-graph/nodes/gstd/src/text.rs | 56 +---------------------- node-graph/nodes/path-bool/Cargo.toml | 1 + node-graph/nodes/path-bool/src/lib.rs | 14 +++++- node-graph/nodes/text/src/to_path.rs | 64 ++++++++++++++++++++++++++- 5 files changed, 79 insertions(+), 57 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 85fd337c97..f1e9968335 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3862,6 +3862,7 @@ dependencies = [ "log", "node-macro", "smallvec", + "text-nodes", "vector-types", ] diff --git a/node-graph/nodes/gstd/src/text.rs b/node-graph/nodes/gstd/src/text.rs index 35fc2eea3b..b2946f8b6b 100644 --- a/node-graph/nodes/gstd/src/text.rs +++ b/node-graph/nodes/gstd/src/text.rs @@ -1,9 +1,5 @@ -use core_types::blending::BlendMode; use core_types::list::List; -use core_types::{ - ATTR_BLEND_MODE, ATTR_EDITOR_LAYER_PATH, ATTR_FONT_SIZE, ATTR_OPACITY, ATTR_OPACITY_FILL, 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, Ctx, -}; +use core_types::{ATTR_FONT_SIZE, 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, Ctx}; use graph_craft::application_io::resource::Resource; use graphic_types::Vector; pub use text_nodes::*; @@ -176,53 +172,5 @@ fn text_to_vector( /// 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: Resource = strings.attribute_cloned_or_default(ATTR_TEXT_FONT, index); - - let typesetting = TypesettingConfig { - font_size: strings.attribute_cloned_or(ATTR_FONT_SIZE, index, DEFAULT_FONT_SIZE), - line_height_ratio: strings.attribute_cloned_or(ATTR_TEXT_LINE_HEIGHT, index, DEFAULT_LINE_HEIGHT), - 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, 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); - let blend_mode = strings.attribute::(ATTR_BLEND_MODE, index).copied(); - let opacity = strings.attribute::(ATTR_OPACITY, index).copied(); - let opacity_fill = strings.attribute::(ATTR_OPACITY_FILL, index).copied(); - - 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()); - } - if let Some(blend_mode) = blend_mode { - item.set_attribute(ATTR_BLEND_MODE, blend_mode); - } - if let Some(opacity) = opacity { - item.set_attribute(ATTR_OPACITY, opacity); - } - if let Some(opacity_fill) = opacity_fill { - item.set_attribute(ATTR_OPACITY_FILL, opacity_fill); - } - result.push(item); - } - } - - result + shape_text_list(&strings, separate_glyphs) } diff --git a/node-graph/nodes/path-bool/Cargo.toml b/node-graph/nodes/path-bool/Cargo.toml index 34e44b9d0c..d6f49a3531 100644 --- a/node-graph/nodes/path-bool/Cargo.toml +++ b/node-graph/nodes/path-bool/Cargo.toml @@ -10,6 +10,7 @@ license = "MIT OR Apache-2.0" # Local dependencies core-types = { workspace = true } graphic-types = { workspace = true } +text-nodes = { workspace = true } node-macro = { workspace = true } glam = { workspace = true } linesweeper = { workspace = true } diff --git a/node-graph/nodes/path-bool/src/lib.rs b/node-graph/nodes/path-bool/src/lib.rs index bce701c3bf..2f686483ce 100644 --- a/node-graph/nodes/path-bool/src/lib.rs +++ b/node-graph/nodes/path-bool/src/lib.rs @@ -278,8 +278,18 @@ fn flatten_vector(graphic_list: &List) -> List { Item::from_parts(element, attributes) }) .collect::>(), - // Text carries no vector geometry until shaped by the 'Text to Vector' node, whose font shaping this crate intentionally doesn't depend on - Graphic::Text(_) => Vec::new(), + Graphic::Text(text) => { + // Shape the glyphs into vectors (each item's own transform is applied), then compose the parent's transform like the other arms + let parent_transform: DAffine2 = graphic_list.attribute_cloned_or_default(ATTR_TRANSFORM, index); + text_nodes::shape_text_list(&text, false) + .into_iter() + .map(|mut sub_vector| { + let current_transform: DAffine2 = sub_vector.attribute_cloned_or_default(ATTR_TRANSFORM); + *sub_vector.attribute_mut_or_insert_default(ATTR_TRANSFORM) = parent_transform * current_transform; + sub_vector + }) + .collect::>() + } } }) .collect() diff --git a/node-graph/nodes/text/src/to_path.rs b/node-graph/nodes/text/src/to_path.rs index 244e6b196c..b1af81c70b 100644 --- a/node-graph/nodes/text/src/to_path.rs +++ b/node-graph/nodes/text/src/to_path.rs @@ -1,7 +1,13 @@ use super::TypesettingConfig; use super::text_context::TextContext; +use core_types::blending::BlendMode; use core_types::list::List; -use glam::DVec2; +use core_types::uuid::NodeId; +use core_types::{ + ATTR_BLEND_MODE, ATTR_EDITOR_LAYER_PATH, ATTR_FONT_SIZE, ATTR_OPACITY, ATTR_OPACITY_FILL, 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 glam::{DAffine2, DVec2}; use graphene_resource::Resource; use vector_types::Vector; @@ -16,3 +22,59 @@ pub fn bounding_box(text: &str, font: &Resource, typesetting: TypesettingConfig, pub fn lines_clipping(text: &str, font: &Resource, typesetting: TypesettingConfig) -> bool { TextContext::with_thread_local(|ctx| ctx.lines_clipping(text, font, typesetting)) } + +/// Shapes each string item of a styled `List` into vector geometry, reading its font and typesetting +/// from the item's attributes (as set by the 'Text Layer' node) and re-applying its transform and blending +/// attributes onto the produced paths. With `separate_glyphs`, each glyph becomes its own item. +pub fn shape_text_list(strings: &List, 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: Resource = strings.attribute_cloned_or_default(ATTR_TEXT_FONT, index); + + let defaults = TypesettingConfig::default(); + let typesetting = TypesettingConfig { + font_size: strings.attribute_cloned_or(ATTR_FONT_SIZE, index, defaults.font_size), + line_height_ratio: strings.attribute_cloned_or(ATTR_TEXT_LINE_HEIGHT, index, defaults.line_height_ratio), + character_spacing: strings.attribute_cloned_or(ATTR_TEXT_CHARACTER_SPACING, index, defaults.character_spacing), + max_width: strings.attribute_cloned_or::>(ATTR_TEXT_MAX_WIDTH, index, defaults.max_width), + max_height: strings.attribute_cloned_or::>(ATTR_TEXT_MAX_HEIGHT, index, defaults.max_height), + tilt: strings.attribute_cloned_or(ATTR_TEXT_TILT, index, defaults.tilt), + align: strings.attribute_cloned_or(ATTR_TEXT_ALIGN, index, defaults.align), + }; + + let vectors = to_path(text, &font, 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); + let blend_mode = strings.attribute::(ATTR_BLEND_MODE, index).copied(); + let opacity = strings.attribute::(ATTR_OPACITY, index).copied(); + let opacity_fill = strings.attribute::(ATTR_OPACITY_FILL, index).copied(); + + for mut item in vectors.into_iter() { + if transform != 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()); + } + if let Some(blend_mode) = blend_mode { + item.set_attribute(ATTR_BLEND_MODE, blend_mode); + } + if let Some(opacity) = opacity { + item.set_attribute(ATTR_OPACITY, opacity); + } + if let Some(opacity_fill) = opacity_fill { + item.set_attribute(ATTR_OPACITY_FILL, opacity_fill); + } + result.push(item); + } + } + + result +} From ce88f12eab0a9bceffc94e7e43b029b29c75a928 Mon Sep 17 00:00:00 2001 From: Annonnymmousss Date: Sun, 14 Jun 2026 01:19:00 +0530 Subject: [PATCH 14/14] chore: Move fallback_font_resource authority from editor to text node --- .../src/messages/portfolio/fonts/fallback.rs | 9 ++++----- node-graph/libraries/rendering/src/renderer.rs | 15 ++++++++++++--- node-graph/nodes/text/src/fallback.rs | 5 +++++ node-graph/nodes/text/src/lib.rs | 2 ++ .../nodes/text/src/source-sans-pro-regular.ttf | Bin 0 -> 119080 bytes node-graph/nodes/text/src/to_path.rs | 6 +++++- 6 files changed, 28 insertions(+), 9 deletions(-) create mode 100644 node-graph/nodes/text/src/fallback.rs create mode 100644 node-graph/nodes/text/src/source-sans-pro-regular.ttf diff --git a/editor/src/messages/portfolio/fonts/fallback.rs b/editor/src/messages/portfolio/fonts/fallback.rs index 40f691a113..0cdcda8cfb 100644 --- a/editor/src/messages/portfolio/fonts/fallback.rs +++ b/editor/src/messages/portfolio/fonts/fallback.rs @@ -1,5 +1,4 @@ -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 = LazyLock::new(|| Resource::new(FALLBACK_FONT_BYTES)); +// Re-export the fallback font resource from text-nodes, which is the authoritative location. +// This avoids duplicating the font bytes in the editor binary. +// This file can be remove after deciding where to place the authority of fallback_resource. +pub use graphene_std::text_nodes::FALLBACK_FONT_RESOURCE; diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 0527a51e5b..945654cc6e 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -2354,7 +2354,10 @@ impl Render for List { 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: Resource = self.attribute_cloned_or_default(ATTR_TEXT_FONT, index); + let font: Resource = { + let f: Resource = self.attribute_cloned_or_default(ATTR_TEXT_FONT, index); + if f.is_empty() { text_nodes::FALLBACK_FONT_RESOURCE.clone() } else { f } + }; 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.); @@ -2504,7 +2507,10 @@ impl Render for List { } let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); - let font: Resource = self.attribute_cloned_or_default(ATTR_TEXT_FONT, index); + let font: Resource = { + let f: Resource = self.attribute_cloned_or_default(ATTR_TEXT_FONT, index); + if f.is_empty() { text_nodes::FALLBACK_FONT_RESOURCE.clone() } else { f } + }; 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.); @@ -2641,7 +2647,10 @@ impl Render for List { 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: Resource = self.attribute_cloned_or_default(ATTR_TEXT_FONT, index); + let font: Resource = { + let f: Resource = self.attribute_cloned_or_default(ATTR_TEXT_FONT, index); + if f.is_empty() { text_nodes::FALLBACK_FONT_RESOURCE.clone() } else { f } + }; 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.); diff --git a/node-graph/nodes/text/src/fallback.rs b/node-graph/nodes/text/src/fallback.rs new file mode 100644 index 0000000000..aa4ce5586e --- /dev/null +++ b/node-graph/nodes/text/src/fallback.rs @@ -0,0 +1,5 @@ +use graphene_resource::Resource; +use std::sync::LazyLock; + +const FALLBACK_FONT_BYTES: &[u8] = include_bytes!("source-sans-pro-regular.ttf"); +pub static FALLBACK_FONT_RESOURCE: LazyLock = LazyLock::new(|| Resource::new(FALLBACK_FONT_BYTES)); diff --git a/node-graph/nodes/text/src/lib.rs b/node-graph/nodes/text/src/lib.rs index 63afabc6b5..6a9e77c314 100644 --- a/node-graph/nodes/text/src/lib.rs +++ b/node-graph/nodes/text/src/lib.rs @@ -1,3 +1,4 @@ +pub mod fallback; mod font; pub mod json; mod path_builder; @@ -16,6 +17,7 @@ use unicode_segmentation::UnicodeSegmentation; // Re-export for convenience pub use core_types as gcore; +pub use fallback::FALLBACK_FONT_RESOURCE; pub use font::*; pub use text_context::TextContext; pub use to_path::*; diff --git a/node-graph/nodes/text/src/source-sans-pro-regular.ttf b/node-graph/nodes/text/src/source-sans-pro-regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..ffe27865aaa6eaf96fefe0800afe02a3be71cb2f GIT binary patch literal 119080 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, separate_glyphs: bool) -> List