From cd337ea9a6b3f0b529fe3161052d6515b0b26985 Mon Sep 17 00:00:00 2001 From: Annonnymmousss Date: Wed, 13 May 2026 03:08:36 +0530 Subject: [PATCH 01/13] 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 f3878680c1..8517bf6c42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4582,7 +4582,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 e70153a389..c87f90b816 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 2d4c5845863d42790481f23d3c34d003603b51f3 Mon Sep 17 00:00:00 2001 From: Annonnymmousss Date: Wed, 13 May 2026 03:56:52 +0530 Subject: [PATCH 02/13] 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 c87f90b816..46638ccfb3 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 eee3368e98e220d4fe947680551cd3d20dbc6c65 Mon Sep 17 00:00:00 2001 From: Annonnymmousss Date: Fri, 15 May 2026 03:22:45 +0530 Subject: [PATCH 03/13] 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 9c13843d5356ee2f978455335d62a2114213ac7d Mon Sep 17 00:00:00 2001 From: Annonnymmousss Date: Sat, 16 May 2026 13:12:55 +0530 Subject: [PATCH 04/13] 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 46638ccfb3..d7c5aa42c1 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 0005a1c3b6eab789ce963e21abe04bf8471ecd3d Mon Sep 17 00:00:00 2001 From: Annonnymmousss Date: Mon, 25 May 2026 03:30:30 +0530 Subject: [PATCH 05/13] 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 0fffabb324..5a987073e3 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 68bbc1685c4d7f12590086a38e0a96fac9419b7d Mon Sep 17 00:00:00 2001 From: Annonnymmousss Date: Mon, 25 May 2026 03:50:54 +0530 Subject: [PATCH 06/13] 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 71cfc84e85d7b59714a6c1ce55acde1f53bb9bee Mon Sep 17 00:00:00 2001 From: Annonnymmousss Date: Mon, 25 May 2026 04:02:36 +0530 Subject: [PATCH 07/13] 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 92da915cd36c14db4df9c52f644dd49b3c94a4f9 Mon Sep 17 00:00:00 2001 From: Annonnymmousss Date: Mon, 25 May 2026 04:30:28 +0530 Subject: [PATCH 08/13] 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 a9cadc64843e04137c6b1afdd7b1de40aaef11e4 Mon Sep 17 00:00:00 2001 From: Annonnymmousss Date: Mon, 25 May 2026 04:31:23 +0530 Subject: [PATCH 09/13] 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 c31ecfc7f2bd63de894f36aa2a9b8e593d5e307b Mon Sep 17 00:00:00 2001 From: Annonnymmousss Date: Sat, 6 Jun 2026 03:11:49 +0530 Subject: [PATCH 10/13] 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 8517bf6c42..ac9cd5352d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4578,6 +4578,7 @@ dependencies = [ "dyn-any", "glam", "graphene-hash", + "graphene-resource", "graphic-types", "kurbo", "log", @@ -4585,6 +4586,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 d7c5aa42c1..21790710f4 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 749f2b10d1959d1fd2414058d65f53dc73321dd4 Mon Sep 17 00:00:00 2001 From: Annonnymmousss Date: Sat, 6 Jun 2026 03:12:41 +0530 Subject: [PATCH 11/13] 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/13] 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 21790710f4..dd94992905 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 3cedd37425a135ea1f9808a665af1838ae3e9a29 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Thu, 11 Jun 2026 16:48:23 -0700 Subject: [PATCH 13/13] 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 ac9cd5352d..e654e47a6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3847,6 +3847,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 +}