From 64d806533b2a143da45946f9502c839d1243908b Mon Sep 17 00:00:00 2001 From: Kulratan Date: Thu, 13 Aug 2026 05:12:31 +0530 Subject: [PATCH 1/4] Implement W3C SVG 2 compliant text-on-path support --- Cargo.lock | 2 +- .../graph_operation_message_handler.rs | 275 ++++++++++- .../document/graph_operation/utility_types.rs | 95 +++- .../document/node_graph/node_properties.rs | 7 +- node-graph/graph-craft/src/document/value.rs | 5 + node-graph/libraries/graphic-types/src/lib.rs | 1 + .../rendering/src/convert_usvg_path.rs | 20 +- .../libraries/rendering/src/renderer.rs | 33 ++ node-graph/libraries/vector-types/src/lib.rs | 1 + .../src/vector/vector_attributes.rs | 1 + .../vector-types/src/vector/vector_types.rs | 53 +++ node-graph/nodes/gstd/src/text.rs | 74 +++ node-graph/nodes/text/Cargo.toml | 1 + node-graph/nodes/text/src/lib.rs | 1 + node-graph/nodes/text/src/path_builder.rs | 72 +++ node-graph/nodes/text/src/text_context.rs | 2 +- node-graph/nodes/text/src/text_on_path.rs | 426 ++++++++++++++++++ node-graph/nodes/vector/src/vector_nodes.rs | 1 + 18 files changed, 1044 insertions(+), 26 deletions(-) create mode 100644 node-graph/nodes/text/src/text_on_path.rs diff --git a/Cargo.lock b/Cargo.lock index bafa4765fc..e66ff1b427 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4660,7 +4660,6 @@ dependencies = [ "glam", "graphene-hash", "graphene-resource", - "image", "kurbo", "ndarray", "no-std-types", @@ -5950,6 +5949,7 @@ dependencies = [ "glam", "graphene-hash", "graphene-resource", + "kurbo", "log", "node-macro", "parley", diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs index aa2dc553b1..e965a9ecd5 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs @@ -11,11 +11,10 @@ use crate::messages::tool::common_functionality::graph_modification_utils::get_c use glam::{DAffine2, DVec2, IVec2}; use graph_craft::document::{NodeId, NodeInput}; use graph_craft::list; -use graphene_std::renderer::convert_usvg_path::convert_usvg_path; +use graphene_std::renderer::convert_usvg_path::{convert_tiny_skia_path, convert_usvg_path}; use graphene_std::text::{Font, TypesettingConfig}; use graphene_std::vector::style::{Gradient, GradientForm, GradientSettings, GradientSpace, GradientSpread, GradientStop, PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; use graphene_std::{Artboard, Color}; - #[derive(ExtractField)] pub struct GraphOperationMessageContext<'a> { pub network_interface: &'a mut NodeNetworkInterface, @@ -472,7 +471,14 @@ impl MessageHandler> for insert_index, center, } => { - let tree = match usvg::Tree::from_str(&svg, &usvg::Options::default()) { + let mut options = usvg::Options::default(); + options.font_family = "Source Sans Pro".to_string(); + + let svg = svg.replace("font-family=\"sans-serif\"", "font-family=\"Source Sans Pro\""); + let svg = svg.replace("font-family='sans-serif'", "font-family='Source Sans Pro'"); + let svg = prepare_svg_textpath_direct_paths(&svg); + + let tree = match usvg::Tree::from_str(&svg, &options) { Ok(t) => t, Err(e) => { responses.add(DialogMessage::DisplayDialogError { @@ -505,9 +511,12 @@ impl MessageHandler> for spaces: extract_gradient_spaces(&svg), }; + // Pre-parse the raw SVG XML for attributes that usvg doesn't expose + let mut textpath_attrs = pre_parse_textpath_attrs(&svg); + // Pass identity so each leaf layer receives only its SVG-native transform from `abs_transform`. // The placement offset is then applied once to the root group layer below. - import_usvg_node(&mut modify_inputs, &usvg::Node::Group(Box::new(tree.root().clone())), id, parent, insert_index, &gradient_info); + import_usvg_node(&mut modify_inputs, &usvg::Node::Group(Box::new(tree.root().clone())), id, parent, insert_index, &gradient_info, &mut textpath_attrs); // After import, `layer_node` is set to the root group. Apply the placement transform to it // (skipped automatically when identity, so file-open with content at origin creates no Transform node). @@ -538,8 +547,10 @@ fn usvg_transform(c: usvg::Transform) -> DAffine2 { } const GRAPHITE_NAMESPACE: &str = "https://graphite.art"; +const XLINK_NAMESPACE: &str = "http://www.w3.org/1999/xlink"; /// Gradient information pre-parsed from the raw SVG XML, carrying what usvg's simplified tree drops. +#[derive(Default)] struct SvgGradientInfo { /// Real stops, keyed by gradient element `id`, for gradients Graphite exported with midpoint curve data. graphite_stops: HashMap, @@ -732,13 +743,128 @@ fn parse_hex_stop_color(hex: &str, opacity: f32) -> Option { Some(Color::from_gamma_srgb_channels(r, g, b, opacity)) } +fn prepare_svg_textpath_direct_paths(svg: &str) -> String { + let doc = match usvg::roxmltree::Document::parse(svg) { + Ok(doc) => doc, + Err(_) => return svg.to_string(), + }; + + let mut edits = Vec::new(); + let mut defs = String::new(); + for (index, node) in doc.descendants().filter(|node| node.tag_name().name() == "textPath").enumerate() { + let Some(path_data) = node.attribute("path").filter(|path| !path.trim().is_empty()) else { + continue; + }; + + let path_id = format!("graphite-textpath-direct-{index}"); + defs.push_str(&format!(r#""#, escape_xml_attr(path_data))); + + if let Some(href_attr) = node + .attributes() + .find(|attr| attr.name() == "href" && (attr.namespace().is_none() || attr.namespace() == Some(XLINK_NAMESPACE))) + { + edits.push((href_attr.range_value(), format!("#{path_id}"))); + } else if let Some(insert_at) = textpath_start_tag_name_end(svg, node) { + edits.push((insert_at..insert_at, format!(r##" href="#{path_id}""##))); + } + } + + if defs.is_empty() { + return svg.to_string(); + } + + if let Some(insert_at) = svg_root_start_tag_end(svg, doc.root_element()) { + edits.push((insert_at..insert_at, format!("{defs}"))); + } + + apply_string_edits(svg, edits) +} + +fn textpath_start_tag_name_end(svg: &str, node: usvg::roxmltree::Node) -> Option { + let start = node.range().start + 1; + svg.get(start..)? + .char_indices() + .find_map(|(offset, c)| matches!(c, ' ' | '\t' | '\n' | '\r' | '/' | '>').then_some(start + offset)) +} + +fn svg_root_start_tag_end(svg: &str, root: usvg::roxmltree::Node) -> Option { + let mut quote = None; + for (offset, c) in svg.get(root.range().start..)?.char_indices() { + match (quote, c) { + (Some(q), c) if c == q => quote = None, + (None, '"' | '\'') => quote = Some(c), + (None, '>') => return Some(root.range().start + offset + 1), + _ => {} + } + } + None +} + +fn apply_string_edits(source: &str, mut edits: Vec<(std::ops::Range, String)>) -> String { + edits.sort_by_key(|(range, _)| range.start); + let mut result = source.to_string(); + for (range, replacement) in edits.into_iter().rev() { + result.replace_range(range, &replacement); + } + result +} + +fn escape_xml_attr(value: &str) -> String { + value.replace('&', "&").replace('"', """).replace('<', "<").replace('>', ">") +} + +#[derive(Debug, Default, Clone)] +struct TextPathAttrs { + pub start_offset: Option, + pub method: Option, + pub spacing: Option, + pub side: Option, + pub text_length: Option, + pub length_adjust: Option, + pub path_length: Option, + pub direction: Option, +} + +fn pre_parse_textpath_attrs(svg: &str) -> std::collections::HashMap> { + let mut map = std::collections::HashMap::>::new(); + let doc = match usvg::roxmltree::Document::parse(svg) { + Ok(doc) => doc, + Err(_) => return map, + }; + for node in doc.descendants() { + if node.tag_name().name() == "textPath" { + let Some(path_id) = textpath_href_id(node) else { + continue; + }; + map.entry(path_id).or_default().push(TextPathAttrs { + start_offset: node.attribute("startOffset").map(str::to_string), + method: node.attribute("method").map(str::to_string), + spacing: node.attribute("spacing").map(str::to_string), + side: node.attribute("side").map(str::to_string), + text_length: node.attribute("textLength").and_then(|v| v.parse().ok()), + length_adjust: node.attribute("lengthAdjust").map(str::to_string), + path_length: node.attribute("pathLength").and_then(|v| v.parse().ok()), + direction: node.attribute("direction").or_else(|| node.attribute("style").and_then(|s| s.split(';').find(|p| p.trim().starts_with("direction")).and_then(|p| p.split(':').last()).map(|v| v.trim()))).map(str::to_string), + }); + } + } + map +} + +fn textpath_href_id(node: usvg::roxmltree::Node) -> Option { + node.attribute((XLINK_NAMESPACE, "href")) + .or_else(|| node.attribute("href")) + .and_then(|href| href.strip_prefix('#')) + .map(str::to_string) +} + /// Import a usvg node as the root of an SVG import operation. /// /// The root layer uses the full `move_layer_to_stack` (with push/collision logic) to correctly /// interact with any existing layers in the parent stack. All descendant layers use a lightweight /// O(n) import path that skips collision detection and instead calculates positions directly from /// the known tree structure. -fn import_usvg_node(modify_inputs: &mut ModifyInputsContext, node: &usvg::Node, id: NodeId, parent: LayerNodeIdentifier, insert_index: usize, gradient_info: &SvgGradientInfo) { +fn import_usvg_node(modify_inputs: &mut ModifyInputsContext, node: &usvg::Node, id: NodeId, parent: LayerNodeIdentifier, insert_index: usize, gradient_info: &SvgGradientInfo, textpath_attrs: &mut HashMap>) { let layer = modify_inputs.create_layer(id); modify_inputs.network_interface.move_layer_to_stack(layer, parent, insert_index, &[]); @@ -758,7 +884,7 @@ fn import_usvg_node(modify_inputs: &mut ModifyInputsContext, node: &usvg::Node, modify_inputs.import = true; for child in group.children() { - let extent = import_usvg_node_inner(modify_inputs, child, NodeId::new(), layer, 0, gradient_info, &mut group_extents_map); + let extent = import_usvg_node_inner(modify_inputs, child, NodeId::new(), layer, 0, gradient_info, &mut group_extents_map, textpath_attrs); child_extents_svg_order.push(extent); } @@ -783,9 +909,8 @@ fn import_usvg_node(modify_inputs: &mut ModifyInputsContext, node: &usvg::Node, warn!("Skip image"); } usvg::Node::Text(text) => { - let font = Font::new(graphene_std::consts::DEFAULT_FONT_FAMILY.to_string(), graphene_std::consts::DEFAULT_FONT_STYLE.to_string()); - modify_inputs.insert_text(text.chunks().iter().map(|chunk| chunk.text()).collect(), font, TypesettingConfig::default(), layer); - modify_inputs.fill_color_set(Some(Color::BLACK)); + log::info!("Importing node as Text: id={}", node.id()); + import_usvg_text(modify_inputs, text, node.abs_transform(), layer, parent, insert_index, textpath_attrs); } } } @@ -803,6 +928,7 @@ fn import_usvg_node_inner( insert_index: usize, gradient_info: &SvgGradientInfo, group_extents_map: &mut HashMap>, + textpath_attrs: &mut HashMap>, ) -> u32 { let layer = modify_inputs.create_layer(id); modify_inputs.network_interface.move_layer_to_stack_for_import(layer, parent, insert_index, &[]); @@ -812,7 +938,7 @@ fn import_usvg_node_inner( usvg::Node::Group(group) => { let mut child_extents: Vec = Vec::new(); for child in group.children() { - let extent = import_usvg_node_inner(modify_inputs, child, NodeId::new(), layer, 0, gradient_info, group_extents_map); + let extent = import_usvg_node_inner(modify_inputs, child, NodeId::new(), layer, 0, gradient_info, group_extents_map, textpath_attrs); child_extents.push(extent); } modify_inputs.layer_node = Some(layer); @@ -826,18 +952,16 @@ fn import_usvg_node_inner( group_extents_map.insert(layer, child_extents); total_extent } - usvg::Node::Path(path) => { - import_usvg_path(modify_inputs, node, path, layer, gradient_info); - 0 - } usvg::Node::Image(_image) => { warn!("Skip image"); 0 } usvg::Node::Text(text) => { - let font = Font::new(graphene_std::consts::DEFAULT_FONT_FAMILY.to_string(), graphene_std::consts::DEFAULT_FONT_STYLE.to_string()); - modify_inputs.insert_text(text.chunks().iter().map(|chunk| chunk.text()).collect(), font, TypesettingConfig::default(), layer); - modify_inputs.fill_color_set(Some(Color::BLACK)); + import_usvg_text(modify_inputs, text, node.abs_transform(), layer, parent, insert_index, textpath_attrs); + 0 + } + usvg::Node::Path(path) => { + import_usvg_path(modify_inputs, node, path, layer, gradient_info); 0 } } @@ -865,6 +989,123 @@ fn import_usvg_path(modify_inputs: &mut ModifyInputsContext, node: &usvg::Node, } } +fn import_usvg_text( + modify_inputs: &mut ModifyInputsContext, + text: &usvg::Text, + transform: usvg::Transform, + layer: LayerNodeIdentifier, + _parent: LayerNodeIdentifier, + _insert_index: usize, + textpath_attrs: &mut HashMap>, +) { + log::info!("Importing usvg text node with {} chunks", text.chunks().len()); + + let chunks = text.chunks(); + for (i, chunk) in chunks.iter().enumerate() { + let current_layer = if chunks.len() > 1 { + let new_id = NodeId::new(); + let new_layer = modify_inputs.create_layer(new_id); + modify_inputs.network_interface.move_layer_to_stack_for_import(new_layer, layer, i, &[]); + new_layer + } else { + layer + }; + modify_inputs.layer_node = Some(current_layer); + + let font_family = chunk + .spans() + .first() + .and_then(|span| span.font().families().first().map(|f| f.to_string())) + .unwrap_or_else(|| graphene_std::consts::DEFAULT_FONT_FAMILY.to_string()); + let font_style = graphene_std::consts::DEFAULT_FONT_STYLE.to_string(); + let font = Font::new(font_family, font_style); + + let font_size = chunk.spans().first().map(|s| s.font_size().get()).unwrap_or(24.0) as f64; + let letter_spacing = chunk.spans().first().map(|s| s.letter_spacing()).unwrap_or(0.0) as f64; + + if let usvg::TextFlow::Path(text_path) = chunk.text_flow() { + let tp_id = text_path.id(); + let tp_attrs = take_textpath_attrs(textpath_attrs, tp_id); + let path_subpaths = convert_tiny_skia_path(text_path.path()); + + let (start_offset, start_offset_percent) = match tp_attrs.start_offset.as_deref() { + Some(s) if s.ends_with('%') => (s.trim_end_matches('%').parse::().unwrap_or(0.0) / 100.0, true), + Some(s) => (s.parse::().unwrap_or(0.0), false), + None => (text_path.start_offset() as f64, false), + }; + + modify_inputs.insert_text_on_path( + chunk.text().to_string(), + font, + font_size, + letter_spacing, + path_subpaths, + start_offset, + start_offset_percent, + text_anchor(chunk.anchor()), + text_path_side(&tp_attrs), + text_path_method(&tp_attrs), + text_path_spacing(&tp_attrs), + tp_attrs.text_length, + text_length_adjust(&tp_attrs), + tp_attrs.path_length, + tp_attrs.direction.as_deref() == Some("rtl"), + usvg_transform(transform), + current_layer, + ); + if let Some(fill) = chunk.spans().first().and_then(|span| span.fill()) { + apply_usvg_fill(fill, modify_inputs, &SvgGradientInfo::default()); + } + } else { + // Regular text fallback + modify_inputs.insert_text(chunk.text().to_string(), font, TypesettingConfig { font_size, ..Default::default() }, current_layer); + if let Some(fill) = chunk.spans().first().and_then(|span| span.fill()) { + apply_usvg_fill(fill, modify_inputs, &SvgGradientInfo::default()); + } + } + } +} + +fn take_textpath_attrs(textpath_attrs: &mut HashMap>, path_id: &str) -> TextPathAttrs { + textpath_attrs.get_mut(path_id).and_then(|attrs| (!attrs.is_empty()).then(|| attrs.remove(0))).unwrap_or_default() +} + +fn text_anchor(anchor: usvg::TextAnchor) -> graphene_std::text::TextAnchor { + match anchor { + usvg::TextAnchor::Start => graphene_std::text::TextAnchor::Start, + usvg::TextAnchor::Middle => graphene_std::text::TextAnchor::Middle, + usvg::TextAnchor::End => graphene_std::text::TextAnchor::End, + } +} + +fn text_path_side(attrs: &TextPathAttrs) -> graphene_std::text::TextPathSide { + match attrs.side.as_deref() { + Some("right") => graphene_std::text::TextPathSide::Right, + _ => graphene_std::text::TextPathSide::Left, + } +} + +fn text_path_method(attrs: &TextPathAttrs) -> graphene_std::text::TextPathMethod { + match attrs.method.as_deref() { + Some("stretch") => graphene_std::text::TextPathMethod::Stretch, + _ => graphene_std::text::TextPathMethod::Align, + } +} + +fn text_path_spacing(attrs: &TextPathAttrs) -> graphene_std::text::TextPathSpacing { + match attrs.spacing.as_deref() { + Some("auto") => graphene_std::text::TextPathSpacing::Auto, + _ => graphene_std::text::TextPathSpacing::Exact, + } +} + +fn text_length_adjust(attrs: &TextPathAttrs) -> graphene_std::text::LengthAdjust { + match attrs.length_adjust.as_deref() { + Some("spacingAndGlyphs") => graphene_std::text::LengthAdjust::SpacingAndGlyphs, + _ => graphene_std::text::LengthAdjust::Spacing, + } +} + /// Set correct positions for all imported layers in a single top-down O(n) pass. /// /// For each group's child stack: 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 d792346fe0..cf8af95828 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -17,7 +17,8 @@ use graphene_std::brush::brush_stroke::BrushStroke; use graphene_std::raster::BlendMode; use graphene_std::raster_types::Image; use graphene_std::subpath::Subpath; -use graphene_std::text::{Font, TypesettingConfig}; +use graphene_std::text::{Font, LengthAdjust, TextAnchor, TextPathMethod, TextPathSide, TextPathSpacing, TypesettingConfig}; +use graphene_std::transform::Transform as _; use graphene_std::vector::style::{GradientForm, GradientHueDirection, GradientInterpolation, GradientSettings, GradientSpace, GradientSpread, Stroke}; use graphene_std::vector::{Gradient, GradientRamp, PointId, Vector, VectorModification, VectorModificationType}; use graphene_std::{Artboard, Color, Graphic}; @@ -234,6 +235,98 @@ impl<'a> ModifyInputsContext<'a> { self.network_interface.move_node_to_chain_start(&fill_id, layer, &[], self.import); } + pub fn insert_text_on_path( + &mut self, + text: String, + font: Font, + font_size: f64, + character_spacing: f64, + path_subpaths: Vec>, + start_offset: f64, + start_offset_percent: bool, + text_anchor: TextAnchor, + side: TextPathSide, + method: TextPathMethod, + spacing: TextPathSpacing, + text_length: Option, + length_adjust: LengthAdjust, + path_length: Option, + rtl: bool, + transform: DAffine2, + layer: LayerNodeIdentifier, + ) { + let font_resource_id = ResourceId::new(); + let path_vector = Vector::from_subpaths(path_subpaths, true); + let path_modification = Box::new(VectorModification::create_from_vector(&path_vector)); + + // The Text On Path node takes its path as an `Item`, which the Path node produces from a `VectorModification`. + let path_node = resolve_network_node_type("Path") + .expect("Path node does not exist") + .node_template_input_override([None, Some(NodeInput::value(TaggedValue::VectorModification(path_modification), false))]); + let path_id = NodeId::new(); + self.network_interface.insert_node(path_id, path_node, &[]); + + let text_on_path_node = resolve_proto_node_type(graphene_std::text::text_on_path::IDENTIFIER) + .expect("Text On Path node does not exist") + .node_template_input_override([ + Some(NodeInput::value(TaggedValue::String(text), false)), + Some(NodeInput::node(path_id, 0)), + Some(NodeInput::value(TaggedValue::Resource(font_resource_id), false)), + Some(NodeInput::value(TaggedValue::F64(font_size), false)), + Some(NodeInput::value(TaggedValue::F64(character_spacing), false)), + Some(NodeInput::value(TaggedValue::F64(start_offset), false)), + Some(NodeInput::value(TaggedValue::Bool(start_offset_percent), false)), + Some(NodeInput::value(TaggedValue::TextPathSide(side), false)), + Some(NodeInput::value(TaggedValue::TextAnchor(text_anchor), false)), + Some(NodeInput::value(TaggedValue::TextPathMethod(method), false)), + Some(NodeInput::value(TaggedValue::TextPathSpacing(spacing), false)), + Some(NodeInput::value(TaggedValue::Bool(text_length.is_some()), false)), + Some(NodeInput::value(TaggedValue::F64(text_length.unwrap_or(0.0)), false)), + Some(NodeInput::value(TaggedValue::LengthAdjust(length_adjust), false)), + Some(NodeInput::value(TaggedValue::Bool(path_length.is_some()), false)), + Some(NodeInput::value(TaggedValue::F64(path_length.unwrap_or(0.0)), false)), + Some(NodeInput::value(TaggedValue::Bool(rtl), false)), + ]); + + let text_on_path_id = NodeId::new(); + self.network_interface.insert_node(text_on_path_id, text_on_path_node, &[]); + self.network_interface.move_node_to_chain_start(&text_on_path_id, layer, &[], self.import); + + self.responses.add(DocumentMessage::Resource(ResourceMessage::AddFont { resource_id: font_resource_id, font })); + + let (rotation, scale, skew): (f64, DVec2, f64) = transform.decompose_rotation_scale_skew(); + let translation = transform.translation; + let rotation = rotation.to_degrees(); + let skew = DVec2::new(skew.atan().to_degrees(), 0.); + + let transform_node = resolve_proto_node_type(graphene_std::transform_nodes::transform::IDENTIFIER) + .expect("Transform node does not exist") + .node_template_input_override([ + None, + Some(NodeInput::value(TaggedValue::DVec2(translation), false)), + Some(NodeInput::value(TaggedValue::F64(rotation), false)), + Some(NodeInput::value(TaggedValue::DVec2(scale), false)), + Some(NodeInput::value(TaggedValue::DVec2(skew), false)), + ]); + let transform_id = NodeId::new(); + self.network_interface.insert_node(transform_id, transform_node, &[]); + self.network_interface.move_node_to_chain_start(&transform_id, layer, &[], self.import); + + let stroke = resolve_proto_node_type(graphene_std::vector_nodes::stroke::IDENTIFIER) + .expect("Stroke node does not exist") + .default_node_template(); + let stroke_id = NodeId::new(); + self.network_interface.insert_node(stroke_id, stroke, &[]); + self.network_interface.move_node_to_chain_start(&stroke_id, layer, &[], self.import); + + let fill = resolve_proto_node_type(graphene_std::vector_nodes::fill::IDENTIFIER) + .expect("Fill node does not exist") + .default_node_template(); + 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, attachment_input: InputConnector) -> NodeId { let color_value = resolve_proto_node_type(graphene_std::math_nodes::color_value::IDENTIFIER) .expect("Color Value node does not exist") diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index 2efeb8b207..4df9b8b731 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -25,7 +25,7 @@ use graphene_std::raster::{ SelectiveColorChoice, }; use graphene_std::raster_types::Image; -use graphene_std::text::{Font, TextAlign}; +use graphene_std::text::{Font, LengthAdjust, TextAlign, TextAnchor, TextPathMethod, TextPathSide, TextPathSpacing}; use graphene_std::text_nodes::StringCapitalization; use graphene_std::transform::{Footprint, ReferencePoint, ScaleType, Transform}; use graphene_std::vector::misc::BooleanOperation; @@ -326,6 +326,11 @@ pub(crate) fn property_from_type( Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), + Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), + Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), + Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), + Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), + Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index 23d2ef25a4..865a527382 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -562,6 +562,11 @@ tagged_value! { CentroidType(vector::misc::CentroidType), BooleanOperation(vector::misc::BooleanOperation), TextAlign(text_nodes::TextAlign), + TextPathSide(text_nodes::text_on_path::TextPathSide), + TextAnchor(text_nodes::text_on_path::TextAnchor), + TextPathMethod(text_nodes::text_on_path::TextPathMethod), + TextPathSpacing(text_nodes::text_on_path::TextPathSpacing), + LengthAdjust(text_nodes::text_on_path::LengthAdjust), ScaleType(core_types::transform::ScaleType), // Legacy #[serde(alias = "Fill")] diff --git a/node-graph/libraries/graphic-types/src/lib.rs b/node-graph/libraries/graphic-types/src/lib.rs index e2795f9933..ca815e8ad2 100644 --- a/node-graph/libraries/graphic-types/src/lib.rs +++ b/node-graph/libraries/graphic-types/src/lib.rs @@ -140,6 +140,7 @@ pub mod migrations { point_domain: old.point_domain, segment_domain: old.segment_domain, region_domain: old.region_domain, + text_on_path_metadata: None, }), VectorFormat::Vector(vector) => Some(vector), VectorFormat::List(list) => list.element.into_iter().next(), diff --git a/node-graph/libraries/rendering/src/convert_usvg_path.rs b/node-graph/libraries/rendering/src/convert_usvg_path.rs index 2f07db846b..a4e346aece 100644 --- a/node-graph/libraries/rendering/src/convert_usvg_path.rs +++ b/node-graph/libraries/rendering/src/convert_usvg_path.rs @@ -3,16 +3,22 @@ use vector_types::subpath::{ManipulatorGroup, Subpath}; use vector_types::vector::PointId; pub fn convert_usvg_path(path: &usvg::Path) -> Vec> { + convert_tiny_skia_path(path.data()) +} + +pub fn convert_tiny_skia_path(path_data: &usvg::tiny_skia_path::Path) -> Vec> { let mut subpaths = Vec::new(); let mut manipulators_list = Vec::new(); - let mut points = path.data().points().iter(); + let mut points = path_data.points().iter(); let to_vec = |p: &usvg::tiny_skia_path::Point| DVec2::new(p.x as f64, p.y as f64); - for verb in path.data().verbs() { + for verb in path_data.verbs() { match verb { usvg::tiny_skia_path::PathVerb::Move => { - subpaths.push(Subpath::new(std::mem::take(&mut manipulators_list), false)); + if !manipulators_list.is_empty() { + subpaths.push(Subpath::new(std::mem::take(&mut manipulators_list), false)); + } let Some(start) = points.next().map(to_vec) else { continue }; manipulators_list.push(ManipulatorGroup::new(start, Some(start), Some(start))); } @@ -38,10 +44,14 @@ pub fn convert_usvg_path(path: &usvg::Path) -> Vec> { manipulators_list.push(ManipulatorGroup::new(end, Some(second_handle), Some(end))); } usvg::tiny_skia_path::PathVerb::Close => { - subpaths.push(Subpath::new(std::mem::take(&mut manipulators_list), true)); + if !manipulators_list.is_empty() { + subpaths.push(Subpath::new(std::mem::take(&mut manipulators_list), true)); + } } } } - subpaths.push(Subpath::new(manipulators_list, false)); + if !manipulators_list.is_empty() { + subpaths.push(Subpath::new(manipulators_list, false)); + } subpaths } diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 274b8cb648..a452421a1c 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -266,6 +266,14 @@ pub fn format_transform_matrix(transform: DAffine2) -> String { }) + ")" } +fn escape_xml_attr(value: &str) -> String { + value.replace('&', "&").replace('"', """).replace('<', "<").replace('>', ">") +} + +fn escape_xml_text(value: &str) -> String { + value.replace('&', "&").replace('<', "<").replace('>', ">") +} + /// `(max, min)` factors by which a unit vector is stretched under `transform`'s linear part — the /// principal and minor singular values, equal to the semi-axes of the ellipse a unit circle maps to. /// Equivalent to `(max(sx, sy), min(sx, sy))` for axis-aligned scales, but accounts for shear. @@ -1383,10 +1391,35 @@ fn render_vector_item_svg(list: &List, index: usize, vector: &Vector, re impl Render for List { fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { let mut clip_mask_state: Option<(u64, MaskType)> = None; + let mut text_on_path_exported = false; for index in 0..self.len() { let Some(vector) = self.element(index) else { continue }; + if render_params.for_export && !text_on_path_exported + && let Some(ref meta) = vector.text_on_path_metadata + { + text_on_path_exported = true; + let path_id = format!("textpath-{}", generate_uuid()); + write!(&mut render.svg_defs, r#""#, escape_xml_attr(&meta.path_d)).unwrap(); + + let font_style_css = escape_xml_attr(&format!("font-family: {}; font-size: {}px; font-style: {};", meta.font_family, meta.font_size, meta.font_style)); + let start_offset_attr = if meta.start_offset_percent { format!("{}%", meta.start_offset * 100.0) } else { format!("{}", meta.start_offset) }; + let matrix = format_transform_matrix(self.attribute_cloned_or_default::(ATTR_TRANSFORM, index)); + let transform_attr = if matrix.is_empty() { String::new() } else { format!(r#" transform="{matrix}""#) }; + let text_length_attr = meta.text_length.map(|tl| format!(r#" textLength="{tl}" lengthAdjust="{}""#, meta.length_adjust)).unwrap_or_default(); + let side_attr = if meta.side == "right" { r#" side="right""# } else { "" }; + let anchor_style = format!("text-anchor: {};", meta.text_anchor); + let method = &meta.method; + let spacing = &meta.spacing; + let direction_attr = if meta.rtl { r#" direction="rtl""# } else { "" }; + let path_length_attr = meta.path_length.map(|pl| format!(r#" pathLength="{pl}""#)).unwrap_or_default(); + let text = escape_xml_text(&meta.text); + + render.leaf_node(format!(r##"{text}"##)); + continue; + } + // A clip-flagged item is masked by its nearest preceding unflagged sibling, which a consecutive run shares let next_clips = index + 1 < self.len() && self.attribute_cloned_or_default::(ATTR_CLIPPING_MASK, index + 1); let mut masked_by = None; diff --git a/node-graph/libraries/vector-types/src/lib.rs b/node-graph/libraries/vector-types/src/lib.rs index d15d4b6a73..31b9dc0f35 100644 --- a/node-graph/libraries/vector-types/src/lib.rs +++ b/node-graph/libraries/vector-types/src/lib.rs @@ -13,6 +13,7 @@ pub use math::{QuadExt, RectExt}; pub use subpath::Subpath; pub use vector::Vector; pub use vector::reference_point::ReferencePoint; +pub use vector::TextOnPathMetadata; // Re-export dependencies that users of this crate will need pub use dyn_any; diff --git a/node-graph/libraries/vector-types/src/vector/vector_attributes.rs b/node-graph/libraries/vector-types/src/vector/vector_attributes.rs index 63f9b87650..6a6a812ab0 100644 --- a/node-graph/libraries/vector-types/src/vector/vector_attributes.rs +++ b/node-graph/libraries/vector-types/src/vector/vector_attributes.rs @@ -1203,6 +1203,7 @@ impl Vector { pub fn transform(&mut self, transform: DAffine2) { self.point_domain.transform(transform); self.segment_domain.transform(transform); + self.text_on_path_metadata = None; } pub fn vector_new_ids_from_hash(&mut self, node_id: u64) { diff --git a/node-graph/libraries/vector-types/src/vector/vector_types.rs b/node-graph/libraries/vector-types/src/vector/vector_types.rs index b576782199..7a855ca382 100644 --- a/node-graph/libraries/vector-types/src/vector/vector_types.rs +++ b/node-graph/libraries/vector-types/src/vector/vector_types.rs @@ -13,6 +13,35 @@ use dyn_any::StaticType; use glam::{DAffine2, DVec2}; use kurbo::{Affine, BezPath, Rect, Shape}; use std::collections::HashMap; +use std::sync::Arc; + +/// Metadata carried by a text-on-path `Vector` to enable lossless SVG `` export. +/// When present on the first row of a `Table`, the SVG renderer emits +/// `` instead of raw `` outlines. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct TextOnPathMetadata { + pub text: String, + pub font_family: String, + pub font_style: String, + pub font_size: f64, + /// SVG path `d` attribute string for the reference path. + pub path_d: String, + pub start_offset: f64, + pub start_offset_percent: bool, + /// "start" | "middle" | "end" + pub text_anchor: String, + /// "left" | "right" + pub side: String, + /// "align" | "stretch" + pub method: String, + /// "exact" | "auto" + pub spacing: String, + pub text_length: Option, + /// "spacing" | "spacingAndGlyphs" + pub length_adjust: String, + pub path_length: Option, + pub rtl: bool, +} /// Represents vector graphics data, composed of Bézier curves in a path or mesh arrangement. #[derive(Clone, Debug, PartialEq)] @@ -27,6 +56,11 @@ pub struct Vector { pub point_domain: PointDomain, pub segment_domain: SegmentDomain, pub region_domain: RegionDomain, + + /// When set, this vector was produced by a text-on-path node. SVG export uses this metadata + /// to emit a `` element instead of raw path outlines. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub text_on_path_metadata: Option>, } unsafe impl StaticType for Vector { type Static = Self; @@ -40,6 +74,7 @@ impl Default for Vector { point_domain: PointDomain::new(), segment_domain: SegmentDomain::new(), region_domain: RegionDomain::new(), + text_on_path_metadata: None, } } } @@ -51,6 +86,23 @@ impl graphene_hash::CacheHash for Vector { self.region_domain.cache_hash(state); self.stroke.cache_hash(state); self.colinear_manipulators.cache_hash(state); + if let Some(metadata) = &self.text_on_path_metadata { + metadata.text.cache_hash(state); + metadata.font_family.cache_hash(state); + metadata.font_style.cache_hash(state); + metadata.font_size.to_bits().cache_hash(state); + metadata.path_d.cache_hash(state); + metadata.start_offset.to_bits().cache_hash(state); + metadata.start_offset_percent.cache_hash(state); + metadata.text_anchor.cache_hash(state); + metadata.side.cache_hash(state); + metadata.method.cache_hash(state); + metadata.spacing.cache_hash(state); + metadata.text_length.map(|tl| tl.to_bits()).cache_hash(state); + metadata.length_adjust.cache_hash(state); + metadata.path_length.map(|pl| pl.to_bits()).cache_hash(state); + metadata.rtl.cache_hash(state); + } } } @@ -82,6 +134,7 @@ impl core_types::transform::BakeTransform for Vector { impl Vector { /// Add a subpath to this vector path. pub fn append_subpath(&mut self, subpath: impl Borrow>, preserve_id: bool) { + self.text_on_path_metadata = None; let subpath: &Subpath = subpath.borrow(); let stroke_id = StrokeId::ZERO; let mut point_id = self.point_domain.next_id(); diff --git a/node-graph/nodes/gstd/src/text.rs b/node-graph/nodes/gstd/src/text.rs index e58f16f590..2e00f95d4b 100644 --- a/node-graph/nodes/gstd/src/text.rs +++ b/node-graph/nodes/gstd/src/text.rs @@ -3,6 +3,7 @@ use core_types::list::{Item, List}; use core_types::{ATTR_FONT, ATTR_FONT_SIZE, ATTR_LETTER_SPACING, ATTR_LETTER_TILT, ATTR_LINE_HEIGHT, ATTR_MAX_HEIGHT, ATTR_MAX_WIDTH, ATTR_TEXT_ALIGN, Ctx}; use graph_craft::application_io::resource::Resource; use graphic_types::Vector; +pub use text_nodes::text_on_path::{LengthAdjust, TextAnchor, TextPathMethod, TextPathSide, TextPathSpacing}; pub use text_nodes::*; /// Produces a styled text string carrying all typographic attributes. @@ -115,3 +116,76 @@ fn text_to_vector_glyphs( ) -> List { shape_text_item(&string, true) } + +/// Flows text glyphs along a vector path following the SVG 2 text-on-path layout rules (§11.8). +#[node_macro::node(category("Text"))] +fn text_on_path( + _: impl Ctx, + /// The text content to flow along the path. + #[default("Lorem ipsum")] + text: Item, + /// The vector path that glyphs follow. + path: Item, + /// The loaded font file used to draw the text. The editor resolves the chosen typeface to these bytes via the resource system. + font: Item, + /// The font size in pixels. + #[unit(" px")] + #[default(24.)] + #[hard_min(1.)] + size: Item, + /// Additional spacing, in pixels, added between each character. + #[unit(" px")] + #[step(0.1)] + character_spacing: Item, + /// Arc-length offset from the path start to the first glyph. + #[unit(" px")] + start_offset: Item, + /// If true, start_offset is treated as a 0–1 fraction of total path length. + start_offset_percent: Item, + /// Which side of the path direction to place text. + side: Item, + /// Text anchor point — affects where along the path the text is anchored. + text_anchor: Item, + /// Glyph rendering method. 'Align' uses rigid transforms; 'Stretch' warps glyphs along the path curvature. + method: Item, + /// Spacing mode. 'Exact' uses computed positions; 'Auto' adjusts for path curvature. + spacing: Item, + /// Whether a forced text length is enabled. + #[widget(ParsedWidgetOverride::Hidden)] + has_text_length: Item, + /// If set, forces the total text advance to this length along the path. + #[unit(" px")] + #[hard_min(0.)] + text_length: Item, + /// How to fit text to the forced text length: adjust spacing only, or spacing and glyph widths. + length_adjust: Item, + /// Whether a custom path authoring length is enabled. + #[widget(ParsedWidgetOverride::Hidden)] + has_path_length: Item, + /// Authoring path length for scaling startOffset. Maps the offset to the actual path length. + #[unit(" px")] + #[hard_min(0.)] + path_length: Item, + /// Right-to-left text direction. + rtl: Item, +) -> List { + let path_list = List::new_from_item(Item::new_from_element(path.into_element())); + text_nodes::text_on_path::place_text_on_path( + text.element(), + &path_list, + font.element(), + *size.element(), + *character_spacing.element(), + *start_offset.element(), + *start_offset_percent.element(), + *side.element(), + *text_anchor.element(), + *method.element(), + *spacing.element(), + (*has_text_length.element()).then_some(*text_length.element()), + *length_adjust.element(), + (*has_path_length.element()).then_some(*path_length.element()), + *rtl.element(), + ) +} + diff --git a/node-graph/nodes/text/Cargo.toml b/node-graph/nodes/text/Cargo.toml index ca9e12e4b0..842071f022 100644 --- a/node-graph/nodes/text/Cargo.toml +++ b/node-graph/nodes/text/Cargo.toml @@ -25,6 +25,7 @@ dyn-any = { workspace = true } glam = { workspace = true } parley = { workspace = true } skrifa = { workspace = true } +kurbo = { workspace = true } log = { workspace = true } serde_json = { workspace = true } convert_case = { workspace = true } diff --git a/node-graph/nodes/text/src/lib.rs b/node-graph/nodes/text/src/lib.rs index 6dd4bd6e57..1f978073b5 100644 --- a/node-graph/nodes/text/src/lib.rs +++ b/node-graph/nodes/text/src/lib.rs @@ -4,6 +4,7 @@ pub mod json; mod path_builder; pub mod regex; mod text_context; +pub mod text_on_path; mod to_path; use convert_case::{Boundary, Converter, pattern}; diff --git a/node-graph/nodes/text/src/path_builder.rs b/node-graph/nodes/text/src/path_builder.rs index facf154c86..11e3ada019 100644 --- a/node-graph/nodes/text/src/path_builder.rs +++ b/node-graph/nodes/text/src/path_builder.rs @@ -105,6 +105,78 @@ impl PathBuilder { has_geometry } + /// Draw a glyph outline in local space and bake the given final transform (position, rotation, scale) + /// directly into its geometry, appending it to the single-item vector list. Used by text-on-path + /// placement, where each glyph sits at an arbitrary position and angle along a path. + pub fn draw_glyph_with_transform( + &mut self, + glyph: &OutlineGlyph<'_>, + size: f32, + normalized_coords: &[NormalizedCoord], + style_skew: Option, + final_transform: DAffine2, + ) { + let saved_origin = self.origin; + let saved_scale = self.scale; + self.origin = DVec2::ZERO; + self.scale = 1.; + + let location_ref = LocationRef::new(normalized_coords); + let settings = DrawSettings::unhinted(Size::new(size), location_ref); + glyph.draw(settings, self).unwrap(); + + self.origin = saved_origin; + self.scale = saved_scale; + + for glyph_subpath in &mut self.glyph_subpaths { + if let Some(style_skew) = style_skew { + glyph_subpath.apply_transform(style_skew); + } + glyph_subpath.apply_transform(final_transform); + } + + for subpath in self.glyph_subpaths.drain(..) { + self.vector_list.element_mut(0).unwrap().append_subpath(subpath, false); + } + } + + /// Draw a glyph outline in local space and remap each of its points through the given function. + /// Used by text-on-path `method="stretch"`, which warps glyph outlines perpendicular to the path. + pub fn draw_glyph_with_mapping( + &mut self, + glyph: &OutlineGlyph<'_>, + size: f32, + normalized_coords: &[NormalizedCoord], + style_skew: Option, + mapping_function: impl Fn(DVec2) -> DVec2, + ) { + let saved_origin = self.origin; + let saved_scale = self.scale; + self.origin = DVec2::ZERO; + self.scale = 1.; + + let location_ref = LocationRef::new(normalized_coords); + let settings = DrawSettings::unhinted(Size::new(size), location_ref); + glyph.draw(settings, self).unwrap(); + + self.origin = saved_origin; + self.scale = saved_scale; + + let subpaths = std::mem::take(&mut self.glyph_subpaths); + for mut subpath in subpaths { + for manipulator_group in subpath.manipulator_groups_mut() { + let transform_point = |point: DVec2| { + let point = style_skew.map_or(point, |skew| skew.transform_point2(point)); + mapping_function(point) + }; + manipulator_group.anchor = transform_point(manipulator_group.anchor); + manipulator_group.in_handle = manipulator_group.in_handle.map(transform_point); + manipulator_group.out_handle = manipulator_group.out_handle.map(transform_point); + } + self.vector_list.element_mut(0).unwrap().append_subpath(subpath, false); + } + } + pub fn render_glyph_run(&mut self, glyph_run: &GlyphRun<'_, ()>, letter_tilt: f64, per_glyph_items: bool, x_offset: f32, space_extra: f32) { let mut run_x = glyph_run.offset() + x_offset; let run_y = glyph_run.baseline(); diff --git a/node-graph/nodes/text/src/text_context.rs b/node-graph/nodes/text/src/text_context.rs index c582035357..5fa284b45d 100644 --- a/node-graph/nodes/text/src/text_context.rs +++ b/node-graph/nodes/text/src/text_context.rs @@ -85,7 +85,7 @@ impl TextContext { } /// Get or cache font information for the given font resource. - fn get_font_info(&mut self, font: &Resource) -> Option<(String, FontInfo)> { + pub(crate) fn get_font_info(&mut self, font: &Resource) -> Option<(String, FontInfo)> { let hash = font.hash(); if let Some((family_id, font_info)) = self.font_info_cache.get(&hash) && let Some(family_name) = self.font_context.collection.family_name(*family_id) diff --git a/node-graph/nodes/text/src/text_on_path.rs b/node-graph/nodes/text/src/text_on_path.rs new file mode 100644 index 0000000000..4bf8a315b3 --- /dev/null +++ b/node-graph/nodes/text/src/text_on_path.rs @@ -0,0 +1,426 @@ +use core_types::graphene_hash::CacheHash; +use core_types::list::List; +use dyn_any::DynAny; +use glam::{DAffine2, DVec2}; +use graphene_resource::Resource; +use kurbo::{BezPath, ParamCurve, ParamCurveArclen, ParamCurveDeriv, PathEl, PathSeg}; +use parley::PositionedLayoutItem; +use skrifa::MetadataProvider; +use skrifa::raw::FontRef as ReadFontsRef; +use std::sync::Arc; +use vector_types::{TextOnPathMetadata, Vector}; + +#[derive(Debug, Default, Clone, Copy, PartialEq, Hash, serde::Serialize, serde::Deserialize, DynAny, node_macro::ChoiceType, CacheHash)] +pub enum TextPathSide { + #[default] + Left, + Right, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Hash, serde::Serialize, serde::Deserialize, DynAny, node_macro::ChoiceType, CacheHash)] +pub enum TextAnchor { + #[default] + Start, + Middle, + End, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Hash, serde::Serialize, serde::Deserialize, DynAny, node_macro::ChoiceType, CacheHash)] +pub enum TextPathMethod { + #[default] + Align, + Stretch, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Hash, serde::Serialize, serde::Deserialize, DynAny, node_macro::ChoiceType, CacheHash)] +pub enum TextPathSpacing { + #[default] + Exact, + Auto, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Hash, serde::Serialize, serde::Deserialize, DynAny, node_macro::ChoiceType, CacheHash)] +pub enum LengthAdjust { + #[default] + Spacing, + SpacingAndGlyphs, +} + +pub struct ArcLengthLut { + lengths: Vec, + params: Vec<(usize, f64)>, + segs: Vec, + pub total_length: f64, + pub is_closed: bool, +} + +impl ArcLengthLut { + pub fn build(path: &BezPath, samples_per_segment: usize) -> Self { + let accuracy = 1e-6; + let samples_per_segment = samples_per_segment.max(1); + let mut lengths = vec![0.0_f64]; + let mut params = vec![(0_usize, 0.0_f64)]; + let mut cumulative = 0.0_f64; + let mut cached_segs = Vec::new(); + + for (seg_idx, seg) in path.segments().enumerate() { + cached_segs.push(seg); + let seg_len = seg.arclen(accuracy); + for i in 1..=samples_per_segment { + let t = i as f64 / samples_per_segment as f64; + let sub_len = seg.subsegment(0.0..t).arclen(accuracy); + lengths.push(cumulative + sub_len); + params.push((seg_idx, t)); + } + cumulative += seg_len; + } + + let is_closed = path.elements().last() == Some(&PathEl::ClosePath); + + Self { + lengths, + params, + segs: cached_segs, + total_length: cumulative, + is_closed, + } + } + + fn eval_tangent(seg: PathSeg, t: f64) -> kurbo::Vec2 { + match seg { + PathSeg::Line(l) => l.deriv().eval(t).to_vec2(), + PathSeg::Quad(q) => q.deriv().eval(t).to_vec2(), + PathSeg::Cubic(c) => c.deriv().eval(t).to_vec2(), + } + } + + pub fn at(&self, mut s: f64) -> Option<(kurbo::Point, f64)> { + if self.total_length < 1e-9 { + return None; + } + + if self.is_closed { + s = s.rem_euclid(self.total_length); + } else if !(0.0..=self.total_length).contains(&s) { + return None; + } + + let idx = self.lengths.partition_point(|&l| l <= s).saturating_sub(1); + let next_idx = (idx + 1).min(self.lengths.len() - 1); + + let l0 = self.lengths[idx]; + let l1 = self.lengths[next_idx]; + + let (seg_idx0, t0) = self.params[idx]; + let (seg_idx1, t1) = self.params[next_idx]; + + // Interpolate t within the segment + let t = if seg_idx0 == seg_idx1 && (l1 - l0) > 1e-9 { t0 + (t1 - t0) * (s - l0) / (l1 - l0) } else { t0 }; + + let seg = self.segs.get(seg_idx0)?; + let point = seg.eval(t); + let tangent = Self::eval_tangent(*seg, t); + + Some((point, tangent.y.atan2(tangent.x))) + } + + fn at_or_zero(&self, s: f64) -> (kurbo::Point, f64) { + self.at(s).unwrap_or((kurbo::Point::ZERO, 0.0)) + } +} + +fn extend_along_tangent(point: kurbo::Point, angle: f64, distance: f64) -> kurbo::Point { + kurbo::Point::new(point.x + distance * angle.cos(), point.y + distance * angle.sin()) +} + +fn at_with_extension(lut: &ArcLengthLut, s: f64) -> (kurbo::Point, f64) { + if (0.0..=lut.total_length).contains(&s) { + return lut.at_or_zero(s); + } + + if s < 0.0 { + let (point, angle) = lut.at_or_zero(0.0); + (extend_along_tangent(point, angle, s), angle) + } else { + let (point, angle) = lut.at_or_zero(lut.total_length); + (extend_along_tangent(point, angle, s - lut.total_length), angle) + } +} + +fn reverse_bezpath(path: BezPath) -> BezPath { + let mut subpaths = Vec::new(); + let mut current_subpath = Vec::new(); + + for el in path.elements() { + match el { + PathEl::MoveTo(_) => { + if !current_subpath.is_empty() { + subpaths.push(BezPath::from_vec(std::mem::take(&mut current_subpath))); + } + current_subpath.push(*el); + } + _ => current_subpath.push(*el), + } + } + if !current_subpath.is_empty() { + subpaths.push(BezPath::from_vec(current_subpath)); + } + + let mut reversed_path = BezPath::new(); + for subpath in subpaths.into_iter().rev() { + let segs: Vec<_> = subpath.segments().collect(); + if segs.is_empty() { + if let Some(PathEl::MoveTo(p)) = subpath.elements().first() { + reversed_path.push(PathEl::MoveTo(*p)); + } + continue; + } + + reversed_path.push(PathEl::MoveTo(segs.last().unwrap().end())); + for seg in segs.iter().rev() { + match seg { + PathSeg::Line(l) => reversed_path.push(PathEl::LineTo(l.p0)), + PathSeg::Quad(q) => reversed_path.push(PathEl::QuadTo(q.p1, q.p0)), + PathSeg::Cubic(c) => reversed_path.push(PathEl::CurveTo(c.p2, c.p1, c.p0)), + } + } + + if subpath.elements().last() == Some(&PathEl::ClosePath) { + reversed_path.push(PathEl::ClosePath); + } + } + reversed_path +} + +fn maybe_reverse_path(path: BezPath, side: TextPathSide) -> BezPath { + match side { + TextPathSide::Left => path, + TextPathSide::Right => reverse_bezpath(path), + } +} + +fn is_glyph_hidden(mid: f64, _start_offset: f64, total_length: f64, is_closed: bool, _text_anchor: TextAnchor, _rtl: bool) -> bool { + if is_closed { + return false; + } + mid < -1e-3 || mid > total_length + 1e-3 +} + +fn resolve_startpoint(abs_offset: f64, total_advance: f64, text_anchor: TextAnchor, rtl: bool) -> f64 { + if !rtl { + match text_anchor { + TextAnchor::Start => abs_offset, + TextAnchor::Middle => abs_offset - total_advance / 2.0, + TextAnchor::End => abs_offset - total_advance, + } + } else { + match text_anchor { + TextAnchor::Start => abs_offset, + TextAnchor::Middle => abs_offset + total_advance / 2.0, + TextAnchor::End => abs_offset + total_advance, + } + } +} + +fn curvature_spacing_adjustment(lut: &ArcLengthLut, mid: f64, advance: f64) -> f64 { + let half = advance / 2.0; + let (_, a0) = at_with_extension(lut, mid - half); + let (_, a1) = at_with_extension(lut, mid + half); + let angle_delta = (a1 - a0 + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI; + advance * angle_delta.abs() * 0.1 +} + +fn text_path_spacing_adjustment(spacing: TextPathSpacing, lut: &ArcLengthLut, mid: f64, advance: f64) -> f64 { + match spacing { + TextPathSpacing::Exact => 0.0, + TextPathSpacing::Auto => curvature_spacing_adjustment(lut, mid, advance), + } +} + +fn point_on_path(lut: &ArcLengthLut, s: f64) -> (kurbo::Point, f64) { + if lut.is_closed { + lut.at_or_zero(s.rem_euclid(lut.total_length)) + } else { + at_with_extension(lut, s) + } +} + +fn stretch_point_on_path(lut: &ArcLengthLut, point: DVec2, origin: f64, advance_scale: f64, baseline_offset: f64) -> DVec2 { + let (path_point, angle) = point_on_path(lut, origin + point.x * advance_scale); + let normal = DVec2::new(-angle.sin(), angle.cos()); + DVec2::new(path_point.x, path_point.y) + normal * (point.y + baseline_offset) +} + +#[allow(clippy::too_many_arguments)] +pub fn place_text_on_path( + text: &str, + path_list: &List, + font: &Resource, + font_size: f64, + character_spacing: f64, + start_offset: f64, + start_offset_percent: bool, + side: TextPathSide, + text_anchor: TextAnchor, + method: TextPathMethod, + spacing: TextPathSpacing, + text_length: Option, + length_adjust: LengthAdjust, + path_length: Option, + rtl: bool, +) -> List { + let Some(original_bezpath) = path_list.element(0).and_then(|vector| vector.stroke_bezpath_iter().find(|p| p.segments().next().is_some())) else { return List::new() }; + let path_d_for_export = original_bezpath.to_svg(); + + let bezpath = maybe_reverse_path(original_bezpath, side); + let lut = ArcLengthLut::build(&bezpath, 100); + if lut.total_length < 1e-9 { return List::new(); } + + let typesetting = crate::TypesettingConfig { + font_size, + letter_spacing: character_spacing, + ..crate::TypesettingConfig::default() + }; + + let layout = crate::TextContext::with_thread_local(|ctx| ctx.layout_text(text, font, typesetting)); + let Some(layout) = layout else { + log::error!("Text layout failed for: {}", text); + return List::new(); + }; + + log::info!("Placing text on path: {} (length: {})", text, lut.total_length); + + let abs_offset = if let Some(pl) = path_length.filter(|&l| l > 1e-9) { + let scale = lut.total_length / pl; + let offset = if start_offset_percent { start_offset * lut.total_length } else { start_offset * scale }; + if rtl { lut.total_length - offset } else { offset } + } else if start_offset_percent { + let offset = start_offset * lut.total_length; + if rtl { lut.total_length - offset } else { offset } + } else if rtl { + lut.total_length - start_offset + } else { + start_offset + }; + + let mut path_builder = crate::path_builder::PathBuilder::new(false, layout.scale() as f64, DVec2::ZERO, DVec2::ZERO); + + layout.lines().for_each(|line| { + let line_width = line.metrics().advance as f64; + + let glyph_count: usize = line.items().map(|item| if let PositionedLayoutItem::GlyphRun(gr) = item { gr.glyphs().count() } else { 0 }).sum(); + + let (advance_scale, spacing_delta) = if let Some(target) = text_length.filter(|&t| t > 0.0 && line_width > 1e-9) { + match length_adjust { + LengthAdjust::Spacing => (1.0, (target - line_width) / glyph_count.saturating_sub(1).max(1) as f64), + LengthAdjust::SpacingAndGlyphs => (target / line_width, 0.0), + } + } else { + (1.0, 0.0) + }; + + let effective_line_width = line_width * advance_scale + spacing_delta * glyph_count.saturating_sub(1) as f64; + let line_start = resolve_startpoint(abs_offset, effective_line_width, text_anchor, rtl); + + let mut cumulative_offset = 0.0_f64; + let mut glyph_index = 0_usize; + + line.items().for_each(|item| { + if let PositionedLayoutItem::GlyphRun(glyph_run) = item { + let mut run_x = glyph_run.offset(); + let run = glyph_run.run(); + let style_skew = run.synthesis().skew().map(|angle| DAffine2::from_cols_array(&[1., 0., -(angle as f64).to_radians().tan(), 1., 0., 0.])); + let run_font = run.font(); + let font_size = run.font_size(); + let normalized_coords = run.normalized_coords().iter().map(|coord| skrifa::instance::NormalizedCoord::from_bits(*coord)).collect::>(); + let Ok(font_ref) = ReadFontsRef::from_index(run_font.data.as_ref(), run_font.index) else { return }; + let outlines = font_ref.outline_glyphs(); + + glyph_run.glyphs().for_each(|glyph| { + let scaled_advance = glyph.advance as f64 * advance_scale; + cumulative_offset += if glyph_index > 0 { spacing_delta } else { 0.0 }; + + let glyph_x_offset = (run_x as f64 - glyph_run.offset() as f64 + glyph.x as f64) * advance_scale + cumulative_offset; + let mid = if rtl { line_start - glyph_x_offset - scaled_advance / 2.0 } else { line_start + glyph_x_offset + scaled_advance / 2.0 }; + + let spacing_adj = text_path_spacing_adjustment(spacing, &lut, mid, scaled_advance); + let adjusted_mid = if rtl { mid - spacing_adj } else { mid + spacing_adj }; + + run_x += glyph.advance; + glyph_index += 1; + + if !is_glyph_hidden(adjusted_mid, abs_offset, lut.total_length, lut.is_closed, text_anchor, rtl) { + if let Some(glyph_outline) = outlines.get(skrifa::GlyphId::from(glyph.id)) { + match method { + TextPathMethod::Align => { + let (point, angle) = point_on_path(&lut, adjusted_mid); + let final_transform = DAffine2::from_translation(DVec2::new(point.x, point.y)) + * DAffine2::from_angle(angle) * DAffine2::from_translation(DVec2::new(-scaled_advance / 2.0, -glyph.y as f64)) + * DAffine2::from_scale(DVec2::new(advance_scale, 1.0)); + path_builder.draw_glyph_with_transform(&glyph_outline, font_size, &normalized_coords, style_skew, final_transform); + } + TextPathMethod::Stretch => { + let stretch_origin = adjusted_mid - scaled_advance / 2.0; + let baseline_offset = -glyph.y as f64; + path_builder.draw_glyph_with_mapping(&glyph_outline, font_size, &normalized_coords, style_skew, |point| { + stretch_point_on_path(&lut, point, stretch_origin, advance_scale, baseline_offset) + }); + } + } + } + } + }); + } + }); + }); + + let mut result = path_builder.finalize(); + + // Attach text-on-path metadata so SVG export can emit instead of raw outlines + let (font_family, font_style) = crate::TextContext::with_thread_local(|ctx| { + ctx.get_font_info(font).map_or((String::new(), String::new()), |(family, info)| (family, info.style().to_string())) + }); + let metadata = Arc::new(TextOnPathMetadata { + text: text.to_string(), + font_family, + font_style, + font_size, + path_d: path_d_for_export, + start_offset, + start_offset_percent, + text_anchor: match text_anchor { + TextAnchor::Start => "start", + TextAnchor::Middle => "middle", + TextAnchor::End => "end", + } + .to_string(), + side: match side { + TextPathSide::Left => "left", + TextPathSide::Right => "right", + } + .to_string(), + method: match method { + TextPathMethod::Align => "align", + TextPathMethod::Stretch => "stretch", + } + .to_string(), + spacing: match spacing { + TextPathSpacing::Exact => "exact", + TextPathSpacing::Auto => "auto", + } + .to_string(), + text_length, + length_adjust: match length_adjust { + LengthAdjust::Spacing => "spacing", + LengthAdjust::SpacingAndGlyphs => "spacingAndGlyphs", + } + .to_string(), + path_length, + rtl, + }); + for vector in result.iter_element_values_mut() { + vector.text_on_path_metadata = Some(Arc::clone(&metadata)); + } + + result +} diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index 2b6780aecf..c934040fa8 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -1812,6 +1812,7 @@ async fn sample_polyline( region_domain: Default::default(), colinear_manipulators: Default::default(), stroke: std::mem::take(&mut content.element_mut().stroke), + ..Default::default() }; // Transfer the stroke transform from the input vector content to the result. result.set_stroke_transform(content.attribute_cloned_or_default(ATTR_TRANSFORM)); From 58e10cb90966255721dcaa6611381ab364b8fdbe Mon Sep 17 00:00:00 2001 From: Kulratan Date: Thu, 13 Aug 2026 05:43:24 +0530 Subject: [PATCH 2/4] Preserve fill and gradient data when exporting and importing text-on-path --- .../graph_operation_message_handler.rs | 9 +++++---- node-graph/libraries/rendering/src/renderer.rs | 15 +++++++++++++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs index e965a9ecd5..2e0c84b559 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs @@ -910,7 +910,7 @@ fn import_usvg_node(modify_inputs: &mut ModifyInputsContext, node: &usvg::Node, } usvg::Node::Text(text) => { log::info!("Importing node as Text: id={}", node.id()); - import_usvg_text(modify_inputs, text, node.abs_transform(), layer, parent, insert_index, textpath_attrs); + import_usvg_text(modify_inputs, text, node.abs_transform(), layer, parent, insert_index, gradient_info, textpath_attrs); } } } @@ -957,7 +957,7 @@ fn import_usvg_node_inner( 0 } usvg::Node::Text(text) => { - import_usvg_text(modify_inputs, text, node.abs_transform(), layer, parent, insert_index, textpath_attrs); + import_usvg_text(modify_inputs, text, node.abs_transform(), layer, parent, insert_index, gradient_info, textpath_attrs); 0 } usvg::Node::Path(path) => { @@ -996,6 +996,7 @@ fn import_usvg_text( layer: LayerNodeIdentifier, _parent: LayerNodeIdentifier, _insert_index: usize, + gradient_info: &SvgGradientInfo, textpath_attrs: &mut HashMap>, ) { log::info!("Importing usvg text node with {} chunks", text.chunks().len()); @@ -1054,13 +1055,13 @@ fn import_usvg_text( current_layer, ); if let Some(fill) = chunk.spans().first().and_then(|span| span.fill()) { - apply_usvg_fill(fill, modify_inputs, &SvgGradientInfo::default()); + apply_usvg_fill(fill, modify_inputs, gradient_info); } } else { // Regular text fallback modify_inputs.insert_text(chunk.text().to_string(), font, TypesettingConfig { font_size, ..Default::default() }, current_layer); if let Some(fill) = chunk.spans().first().and_then(|span| span.fill()) { - apply_usvg_fill(fill, modify_inputs, &SvgGradientInfo::default()); + apply_usvg_fill(fill, modify_inputs, gradient_info); } } } diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index a452421a1c..ec083f151c 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -1405,7 +1405,8 @@ impl Render for List { let font_style_css = escape_xml_attr(&format!("font-family: {}; font-size: {}px; font-style: {};", meta.font_family, meta.font_size, meta.font_style)); let start_offset_attr = if meta.start_offset_percent { format!("{}%", meta.start_offset * 100.0) } else { format!("{}", meta.start_offset) }; - let matrix = format_transform_matrix(self.attribute_cloned_or_default::(ATTR_TRANSFORM, index)); + let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); + let matrix = format_transform_matrix(item_transform); let transform_attr = if matrix.is_empty() { String::new() } else { format!(r#" transform="{matrix}""#) }; let text_length_attr = meta.text_length.map(|tl| format!(r#" textLength="{tl}" lengthAdjust="{}""#, meta.length_adjust)).unwrap_or_default(); let side_attr = if meta.side == "right" { r#" side="right""# } else { "" }; @@ -1416,7 +1417,17 @@ impl Render for List { let path_length_attr = meta.path_length.map(|pl| format!(r#" pathLength="{pl}""#)).unwrap_or_default(); let text = escape_xml_text(&meta.text); - render.leaf_node(format!(r##"{text}"##)); + // The text element carries the same paint the outlines would: the fill (and its opacity) set via a Fill node. + let fill_attr = graphic_list_at(self, index, ATTR_FILL) + .as_deref() + .map(|list| list.render(&mut render.svg_defs, item_transform, item_transform, item_transform, DAffine2::IDENTITY, &render_params, PaintTarget::Fill)) + .unwrap_or_default(); + let opacity_attr = self.attribute_cloned_or(ATTR_OPACITY, index, 1.); + let opacity_fill_attr = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.); + let opacity = opacity_attr * opacity_fill_attr; + let opacity_attr_str = if opacity < 1. { format!(r#" opacity="{opacity}""#) } else { String::new() }; + + render.leaf_node(format!(r##"{text}"##)); continue; } From 4630515e50b9681bc4a1b2b27dfd5a3bdc8d86f0 Mon Sep 17 00:00:00 2001 From: Kulratan Date: Thu, 13 Aug 2026 06:40:30 +0000 Subject: [PATCH 3/4] fmt --- .../graph_operation_message_handler.rs | 28 +++++++++++++++++-- .../libraries/rendering/src/renderer.rs | 21 ++++++++++++-- node-graph/libraries/vector-types/src/lib.rs | 2 +- node-graph/nodes/gstd/src/text.rs | 1 - node-graph/nodes/text/src/path_builder.rs | 18 ++---------- node-graph/nodes/text/src/text_on_path.rs | 22 +++++++++------ 6 files changed, 60 insertions(+), 32 deletions(-) diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs index 2e0c84b559..b154ebc41b 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs @@ -516,7 +516,15 @@ impl MessageHandler> for // Pass identity so each leaf layer receives only its SVG-native transform from `abs_transform`. // The placement offset is then applied once to the root group layer below. - import_usvg_node(&mut modify_inputs, &usvg::Node::Group(Box::new(tree.root().clone())), id, parent, insert_index, &gradient_info, &mut textpath_attrs); + import_usvg_node( + &mut modify_inputs, + &usvg::Node::Group(Box::new(tree.root().clone())), + id, + parent, + insert_index, + &gradient_info, + &mut textpath_attrs, + ); // After import, `layer_node` is set to the root group. Apply the placement transform to it // (skipped automatically when identity, so file-open with content at origin creates no Transform node). @@ -844,7 +852,13 @@ fn pre_parse_textpath_attrs(svg: &str) -> std::collections::HashMap Option { /// interact with any existing layers in the parent stack. All descendant layers use a lightweight /// O(n) import path that skips collision detection and instead calculates positions directly from /// the known tree structure. -fn import_usvg_node(modify_inputs: &mut ModifyInputsContext, node: &usvg::Node, id: NodeId, parent: LayerNodeIdentifier, insert_index: usize, gradient_info: &SvgGradientInfo, textpath_attrs: &mut HashMap>) { +fn import_usvg_node( + modify_inputs: &mut ModifyInputsContext, + node: &usvg::Node, + id: NodeId, + parent: LayerNodeIdentifier, + insert_index: usize, + gradient_info: &SvgGradientInfo, + textpath_attrs: &mut HashMap>, +) { let layer = modify_inputs.create_layer(id); modify_inputs.network_interface.move_layer_to_stack(layer, parent, insert_index, &[]); diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index ec083f151c..f1cf12bc10 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -1396,7 +1396,8 @@ impl Render for List { for index in 0..self.len() { let Some(vector) = self.element(index) else { continue }; - if render_params.for_export && !text_on_path_exported + if render_params.for_export + && !text_on_path_exported && let Some(ref meta) = vector.text_on_path_metadata { text_on_path_exported = true; @@ -1404,7 +1405,11 @@ impl Render for List { write!(&mut render.svg_defs, r#""#, escape_xml_attr(&meta.path_d)).unwrap(); let font_style_css = escape_xml_attr(&format!("font-family: {}; font-size: {}px; font-style: {};", meta.font_family, meta.font_size, meta.font_style)); - let start_offset_attr = if meta.start_offset_percent { format!("{}%", meta.start_offset * 100.0) } else { format!("{}", meta.start_offset) }; + let start_offset_attr = if meta.start_offset_percent { + format!("{}%", meta.start_offset * 100.0) + } else { + format!("{}", meta.start_offset) + }; let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); let matrix = format_transform_matrix(item_transform); let transform_attr = if matrix.is_empty() { String::new() } else { format!(r#" transform="{matrix}""#) }; @@ -1420,7 +1425,17 @@ impl Render for List { // The text element carries the same paint the outlines would: the fill (and its opacity) set via a Fill node. let fill_attr = graphic_list_at(self, index, ATTR_FILL) .as_deref() - .map(|list| list.render(&mut render.svg_defs, item_transform, item_transform, item_transform, DAffine2::IDENTITY, &render_params, PaintTarget::Fill)) + .map(|list| { + list.render( + &mut render.svg_defs, + item_transform, + item_transform, + item_transform, + DAffine2::IDENTITY, + &render_params, + PaintTarget::Fill, + ) + }) .unwrap_or_default(); let opacity_attr = self.attribute_cloned_or(ATTR_OPACITY, index, 1.); let opacity_fill_attr = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.); diff --git a/node-graph/libraries/vector-types/src/lib.rs b/node-graph/libraries/vector-types/src/lib.rs index 31b9dc0f35..d61d4409a6 100644 --- a/node-graph/libraries/vector-types/src/lib.rs +++ b/node-graph/libraries/vector-types/src/lib.rs @@ -11,9 +11,9 @@ pub use core_types as gcore; pub use gradient::{Gradient, GradientForm, GradientHueDirection, GradientInterpolation, GradientRamp, GradientSettings, GradientSpace, GradientSpread, GradientStop}; pub use math::{QuadExt, RectExt}; pub use subpath::Subpath; +pub use vector::TextOnPathMetadata; pub use vector::Vector; pub use vector::reference_point::ReferencePoint; -pub use vector::TextOnPathMetadata; // Re-export dependencies that users of this crate will need pub use dyn_any; diff --git a/node-graph/nodes/gstd/src/text.rs b/node-graph/nodes/gstd/src/text.rs index 2e00f95d4b..51589a1105 100644 --- a/node-graph/nodes/gstd/src/text.rs +++ b/node-graph/nodes/gstd/src/text.rs @@ -188,4 +188,3 @@ fn text_on_path( *rtl.element(), ) } - diff --git a/node-graph/nodes/text/src/path_builder.rs b/node-graph/nodes/text/src/path_builder.rs index 11e3ada019..defeaf1294 100644 --- a/node-graph/nodes/text/src/path_builder.rs +++ b/node-graph/nodes/text/src/path_builder.rs @@ -108,14 +108,7 @@ impl PathBuilder { /// Draw a glyph outline in local space and bake the given final transform (position, rotation, scale) /// directly into its geometry, appending it to the single-item vector list. Used by text-on-path /// placement, where each glyph sits at an arbitrary position and angle along a path. - pub fn draw_glyph_with_transform( - &mut self, - glyph: &OutlineGlyph<'_>, - size: f32, - normalized_coords: &[NormalizedCoord], - style_skew: Option, - final_transform: DAffine2, - ) { + pub fn draw_glyph_with_transform(&mut self, glyph: &OutlineGlyph<'_>, size: f32, normalized_coords: &[NormalizedCoord], style_skew: Option, final_transform: DAffine2) { let saved_origin = self.origin; let saved_scale = self.scale; self.origin = DVec2::ZERO; @@ -142,14 +135,7 @@ impl PathBuilder { /// Draw a glyph outline in local space and remap each of its points through the given function. /// Used by text-on-path `method="stretch"`, which warps glyph outlines perpendicular to the path. - pub fn draw_glyph_with_mapping( - &mut self, - glyph: &OutlineGlyph<'_>, - size: f32, - normalized_coords: &[NormalizedCoord], - style_skew: Option, - mapping_function: impl Fn(DVec2) -> DVec2, - ) { + pub fn draw_glyph_with_mapping(&mut self, glyph: &OutlineGlyph<'_>, size: f32, normalized_coords: &[NormalizedCoord], style_skew: Option, mapping_function: impl Fn(DVec2) -> DVec2) { let saved_origin = self.origin; let saved_scale = self.scale; self.origin = DVec2::ZERO; diff --git a/node-graph/nodes/text/src/text_on_path.rs b/node-graph/nodes/text/src/text_on_path.rs index 4bf8a315b3..38fc18dfca 100644 --- a/node-graph/nodes/text/src/text_on_path.rs +++ b/node-graph/nodes/text/src/text_on_path.rs @@ -269,12 +269,16 @@ pub fn place_text_on_path( path_length: Option, rtl: bool, ) -> List { - let Some(original_bezpath) = path_list.element(0).and_then(|vector| vector.stroke_bezpath_iter().find(|p| p.segments().next().is_some())) else { return List::new() }; + let Some(original_bezpath) = path_list.element(0).and_then(|vector| vector.stroke_bezpath_iter().find(|p| p.segments().next().is_some())) else { + return List::new(); + }; let path_d_for_export = original_bezpath.to_svg(); let bezpath = maybe_reverse_path(original_bezpath, side); let lut = ArcLengthLut::build(&bezpath, 100); - if lut.total_length < 1e-9 { return List::new(); } + if lut.total_length < 1e-9 { + return List::new(); + } let typesetting = crate::TypesettingConfig { font_size, @@ -339,10 +343,14 @@ pub fn place_text_on_path( glyph_run.glyphs().for_each(|glyph| { let scaled_advance = glyph.advance as f64 * advance_scale; cumulative_offset += if glyph_index > 0 { spacing_delta } else { 0.0 }; - + let glyph_x_offset = (run_x as f64 - glyph_run.offset() as f64 + glyph.x as f64) * advance_scale + cumulative_offset; - let mid = if rtl { line_start - glyph_x_offset - scaled_advance / 2.0 } else { line_start + glyph_x_offset + scaled_advance / 2.0 }; - + let mid = if rtl { + line_start - glyph_x_offset - scaled_advance / 2.0 + } else { + line_start + glyph_x_offset + scaled_advance / 2.0 + }; + let spacing_adj = text_path_spacing_adjustment(spacing, &lut, mid, scaled_advance); let adjusted_mid = if rtl { mid - spacing_adj } else { mid + spacing_adj }; @@ -377,9 +385,7 @@ pub fn place_text_on_path( let mut result = path_builder.finalize(); // Attach text-on-path metadata so SVG export can emit instead of raw outlines - let (font_family, font_style) = crate::TextContext::with_thread_local(|ctx| { - ctx.get_font_info(font).map_or((String::new(), String::new()), |(family, info)| (family, info.style().to_string())) - }); + let (font_family, font_style) = crate::TextContext::with_thread_local(|ctx| ctx.get_font_info(font).map_or((String::new(), String::new()), |(family, info)| (family, info.style().to_string()))); let metadata = Arc::new(TextOnPathMetadata { text: text.to_string(), font_family, From d3638b9683e3a79673098658545014dd77ae397a Mon Sep 17 00:00:00 2001 From: Kulratan Date: Thu, 13 Aug 2026 12:14:19 +0530 Subject: [PATCH 4/4] fix --- Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.lock b/Cargo.lock index e66ff1b427..00e12246b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4660,6 +4660,7 @@ dependencies = [ "glam", "graphene-hash", "graphene-resource", + "image", "kurbo", "ndarray", "no-std-types",