From 635605c7bab8d6792bde8ed60a05aa9e60c5fdb8 Mon Sep 17 00:00:00 2001 From: Kulratan Date: Sat, 2 May 2026 22:37:59 +0000 Subject: [PATCH 1/5] Implement W3C SVG 2 compliant text-on-path support --- Cargo.lock | 1 + Cargo.toml | 2 +- .../graph_operation_message_handler.rs | 128 ++++++- .../document/graph_operation/utility_types.rs | 81 +++- .../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 | 21 ++ node-graph/libraries/vector-types/src/lib.rs | 1 + .../vector-types/src/vector/vector_types.rs | 35 +- node-graph/nodes/gstd/src/text.rs | 75 ++++ node-graph/nodes/text/Cargo.toml | 1 + node-graph/nodes/text/src/font_cache.rs | 97 ++--- node-graph/nodes/text/src/lib.rs | 1 + node-graph/nodes/text/src/path_builder.rs | 171 ++++----- node-graph/nodes/text/src/text_context.rs | 2 +- node-graph/nodes/text/src/text_on_path.rs | 347 ++++++++++++++++++ node-graph/nodes/vector/src/vector_nodes.rs | 1 + 19 files changed, 839 insertions(+), 158 deletions(-) create mode 100644 node-graph/nodes/text/src/text_on_path.rs diff --git a/Cargo.lock b/Cargo.lock index 4aae673311..79b0eebbaa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5796,6 +5796,7 @@ dependencies = [ "core-types", "dyn-any", "glam", + "kurbo", "log", "node-macro", "parley", diff --git a/Cargo.toml b/Cargo.toml index 468b88ea79..6bc13cffa9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -150,7 +150,7 @@ kurbo = { version = "0.13", features = ["serde"] } vello = "0.7" vello_encoding = "0.7" resvg = "0.47" -usvg = "0.47" +usvg = { version = "0.47", features = ["text", "system-fonts", "memmap-fonts"] } parley = "0.6" skrifa = "0.40" polycool = "0.4" 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 46d9116483..f875da6cec 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 @@ -12,11 +12,10 @@ use graph_craft::document::value::TaggedValue; use graph_craft::document::{NodeId, NodeInput}; use graphene_std::Color; use graphene_std::renderer::Quad; -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::table::Table; use graphene_std::text::{Font, TypesettingConfig}; use graphene_std::vector::style::{Fill, Gradient, GradientStop, GradientStops, GradientType, PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; - #[derive(ExtractField)] pub struct GraphOperationMessageContext<'a> { pub network_interface: &'a mut NodeNetworkInterface, @@ -394,7 +393,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.fontdb_mut().load_font_data(include_bytes!("../overlays/source-sans-pro-regular.ttf").to_vec()); + 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 tree = match usvg::Tree::from_str(&svg, &options) { Ok(t) => t, Err(e) => { responses.add(DialogMessage::DisplayDialogError { @@ -424,6 +430,9 @@ impl MessageHandler> for let graphite_gradient_stops = extract_graphite_gradient_stops(&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( @@ -433,6 +442,7 @@ impl MessageHandler> for parent, insert_index, &graphite_gradient_stops, + &mut textpath_attrs, ); // After import, `layer_node` is set to the root group. Apply the placement transform to it @@ -532,6 +542,36 @@ fn parse_hex_stop_color(hex: &str, opacity: f32) -> Option { Some(Color::from_rgbaf32_unchecked(r, g, b, opacity)) } +#[derive(Debug, Default, Clone)] +struct TextPathAttrs { + pub method: Option, + pub spacing: Option, + pub side: Option, + pub text_length: Option, + pub length_adjust: 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 id = node.attribute("id").unwrap_or("").to_string(); + map.entry(id).or_default().push(TextPathAttrs { + 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), + }); + } + } + map +} + /// 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 @@ -545,6 +585,7 @@ fn import_usvg_node( parent: LayerNodeIdentifier, insert_index: usize, graphite_gradient_stops: &HashMap, + textpath_attrs: &mut HashMap>, ) { let layer = modify_inputs.create_layer(id); @@ -565,7 +606,7 @@ fn import_usvg_node( modify_inputs.import = true; for child in group.children() { - let extent = import_usvg_node_inner(modify_inputs, child, NodeId::new(), layer, 0, graphite_gradient_stops, &mut group_extents_map); + let extent = import_usvg_node_inner(modify_inputs, child, NodeId::new(), layer, 0, graphite_gradient_stops, &mut group_extents_map, textpath_attrs); child_extents_svg_order.push(extent); } @@ -590,9 +631,7 @@ fn import_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_set(Fill::Solid(Color::BLACK)); + import_usvg_text(modify_inputs, text, node.abs_transform(), layer, parent, insert_index, textpath_attrs); } } } @@ -610,6 +649,7 @@ fn import_usvg_node_inner( insert_index: usize, graphite_gradient_stops: &HashMap, 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, &[]); @@ -619,7 +659,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, graphite_gradient_stops, group_extents_map); + let extent = import_usvg_node_inner(modify_inputs, child, NodeId::new(), layer, 0, graphite_gradient_stops, group_extents_map, textpath_attrs); child_extents.push(extent); } modify_inputs.layer_node = Some(layer); @@ -633,24 +673,21 @@ 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, graphite_gradient_stops); - 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_set(Fill::Solid(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, graphite_gradient_stops); 0 } } } -/// Helper to apply path data (vector geometry, fill, stroke, transform) to a layer. fn import_usvg_path(modify_inputs: &mut ModifyInputsContext, node: &usvg::Node, path: &usvg::Path, layer: LayerNodeIdentifier, graphite_gradient_stops: &HashMap) { let subpaths = convert_usvg_path(path); let bounds = subpaths.iter().filter_map(|subpath| subpath.bounding_box()).reduce(Quad::combine_bounds).unwrap_or_default(); @@ -674,6 +711,65 @@ 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>) { + use graphene_std::text::{LengthAdjust, TextAnchor, TextPathMethod, TextPathSide, TextPathSpacing}; + + for (i, chunk) in text.chunks().iter().enumerate() { + let current_layer = if i == 0 { + layer + } else { + 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, parent, insert_index, &[]); + new_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 = textpath_attrs.get_mut(tp_id).and_then(|vec| if !vec.is_empty() { Some(vec.remove(0)) } else { None }).unwrap_or_default(); + let path_subpaths = convert_tiny_skia_path(text_path.path()); + let start_offset = text_path.start_offset() as f64; + let anchor = match chunk.anchor() { + usvg::TextAnchor::Start => TextAnchor::Start, + usvg::TextAnchor::Middle => TextAnchor::Middle, + usvg::TextAnchor::End => TextAnchor::End, + }; + + let affine = DAffine2::from_cols_array(&[ + transform.sx as f64, + transform.ky as f64, + transform.kx as f64, + transform.sy as f64, + transform.tx as f64, + transform.ty as f64, + ]); + let method = if tp_attrs.method.as_deref() == Some("stretch") { TextPathMethod::Stretch } else { TextPathMethod::Align }; + let spacing = if tp_attrs.spacing.as_deref() == Some("auto") { TextPathSpacing::Auto } else { TextPathSpacing::Exact }; + let side = if tp_attrs.side.as_deref() == Some("right") { TextPathSide::Right } else { TextPathSide::Left }; + let length_adjust = if tp_attrs.length_adjust.as_deref() == Some("spacingAndGlyphs") { LengthAdjust::SpacingAndGlyphs } else { LengthAdjust::Spacing }; + + modify_inputs.insert_text_on_path(chunk.text().to_string(), font, font_size, letter_spacing, path_subpaths, start_offset, anchor, side, method, spacing, tp_attrs.text_length, length_adjust, affine, current_layer); + modify_inputs.fill_set(Fill::Solid(Color::BLACK)); + } else { + // Regular text fallback + modify_inputs.insert_text(chunk.text().to_string(), font, TypesettingConfig { font_size, ..Default::default() }, current_layer); + modify_inputs.fill_set(Fill::Solid(Color::BLACK)); + } + } +} + /// 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 066076da1a..239cab8fb2 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -3,7 +3,7 @@ use crate::messages::portfolio::document::node_graph::document_node_definitions: use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::network_interface::{self, InputConnector, NodeNetworkInterface, OutputConnector}; use crate::messages::prelude::*; -use glam::{DAffine2, IVec2}; +use glam::{DAffine2, DVec2, IVec2}; use graph_craft::document::value::TaggedValue; use graph_craft::document::{NodeId, NodeInput}; use graph_craft::{ProtoNodeIdentifier, concrete}; @@ -13,7 +13,8 @@ use graphene_std::raster::BlendMode; use graphene_std::raster_types::{CPU, Raster}; use graphene_std::subpath::Subpath; use graphene_std::table::Table; -use graphene_std::text::{Font, TypesettingConfig}; +use graphene_std::text::{Font, TextAnchor, TypesettingConfig}; +use graphene_std::transform::Transform as _; use graphene_std::vector::Vector; use graphene_std::vector::style::{Fill, Stroke}; use graphene_std::vector::{PointId, VectorModificationType}; @@ -289,6 +290,82 @@ 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, + text_anchor: TextAnchor, + side: graphene_std::text::TextPathSide, + method: graphene_std::text::TextPathMethod, + spacing: graphene_std::text::TextPathSpacing, + text_length: Option, + length_adjust: graphene_std::text::LengthAdjust, + transform: DAffine2, + layer: LayerNodeIdentifier, + ) { + let path_vector = Table::new_from_element(Vector::from_subpaths(path_subpaths, true)); + 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::scope("editor-api")), + Some(NodeInput::value(TaggedValue::String(text), false)), + Some(NodeInput::value(TaggedValue::Vector(path_vector), false)), + Some(NodeInput::value(TaggedValue::Font(font), 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(false), 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(false), false)), + Some(NodeInput::value(TaggedValue::F64(0.0), false)), + Some(NodeInput::value(TaggedValue::Bool(false), 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); + + 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_network_node_type("Transform").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_image_data(&mut self, image_frame: Table>, layer: LayerNodeIdentifier) { let transform = resolve_network_node_type("Transform").expect("Transform node does not exist").default_node_template(); let image = resolve_proto_node_type(graphene_std::raster_nodes::std_nodes::image_value::IDENTIFIER) 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 a84e5cb127..a5c11a08df 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -22,7 +22,7 @@ use graphene_std::raster::{ SelectiveColorChoice, }; use graphene_std::table::{Table, TableRow}; -use graphene_std::text::{Font, TextAlign}; +use graphene_std::text::{Font, LengthAdjust, TextAlign, TextAnchor, TextPathMethod, TextPathSide, TextPathSpacing}; use graphene_std::transform::{Footprint, ReferencePoint, ScaleType, Transform}; use graphene_std::vector::QRCodeErrorCorrectionLevel; use graphene_std::vector::misc::BooleanOperation; @@ -258,6 +258,11 @@ pub(crate) fn property_from_type( Some(x) if x == TypeId::of::() => enum_choice::().for_socket(default_info).property_row(), Some(x) if x == TypeId::of::() => enum_choice::().for_socket(default_info).property_row(), Some(x) if x == TypeId::of::() => enum_choice::().for_socket(default_info).property_row(), + Some(x) if x == TypeId::of::() => enum_choice::().for_socket(default_info).property_row(), + Some(x) if x == TypeId::of::() => enum_choice::().for_socket(default_info).property_row(), + Some(x) if x == TypeId::of::() => enum_choice::().for_socket(default_info).property_row(), + Some(x) if x == TypeId::of::() => enum_choice::().for_socket(default_info).property_row(), + Some(x) if x == TypeId::of::() => enum_choice::().for_socket(default_info).property_row(), Some(x) if x == TypeId::of::() => enum_choice::().for_socket(default_info).property_row(), Some(x) if x == TypeId::of::() => enum_choice::().for_socket(default_info).property_row(), Some(x) if x == TypeId::of::() => 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 d38b556354..80ea81cf17 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -273,6 +273,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), } diff --git a/node-graph/libraries/graphic-types/src/lib.rs b/node-graph/libraries/graphic-types/src/lib.rs index 54e4302bca..7adf617440 100644 --- a/node-graph/libraries/graphic-types/src/lib.rs +++ b/node-graph/libraries/graphic-types/src/lib.rs @@ -77,6 +77,7 @@ pub mod migrations { segment_domain: old.segment_domain, region_domain: old.region_domain, upstream_data: old.upstream_graphic_group, + text_on_path_metadata: None, }); *vector_table.iter_mut().next().unwrap().transform = old.transform; *vector_table.iter_mut().next().unwrap().alpha_blending = old.alpha_blending; 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 c589e6177d..8145209a4a 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -787,6 +787,27 @@ impl Render for Table { impl Render for Table { fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { for row in self.iter() { + if render_params.for_export { + if let Some(ref meta) = row.element.text_on_path_metadata { + let path_id = format!("textpath-{}", generate_uuid()); + write!(&mut render.svg_defs, r#""#, meta.path_d).unwrap(); + + let font_style_css = 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(*row.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 { "" }; + let anchor_style = format!("text-anchor: {};", meta.text_anchor); + let method = &meta.method; + let spacing = &meta.spacing; + let text = &meta.text; + + render.leaf_node(format!(r##"{text}"##)); + continue; + } + } + let multiplied_transform = *row.transform; let vector = &row.element; // Only consider strokes with non-zero weight, since default strokes with zero weight would prevent assigning the correct stroke transform diff --git a/node-graph/libraries/vector-types/src/lib.rs b/node-graph/libraries/vector-types/src/lib.rs index f5dc06c245..76210a92d6 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_types.rs b/node-graph/libraries/vector-types/src/vector/vector_types.rs index e9b8bbb670..13842dfc3b 100644 --- a/node-graph/libraries/vector-types/src/vector/vector_types.rs +++ b/node-graph/libraries/vector-types/src/vector/vector_types.rs @@ -14,6 +14,33 @@ 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, +} /// Represents vector graphics data, composed of Bézier curves in a path or mesh arrangement. /// @@ -36,6 +63,11 @@ pub struct Vector { /// Without this, the tools would be working with a collapsed version of the data which has no reference to the original child layers that were booleaned together, resulting in the inner layers not being editable. #[serde(alias = "upstream_group")] pub upstream_data: Upstream, + + /// 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; @@ -50,6 +82,7 @@ impl Default for Vector { segment_domain: SegmentDomain::new(), region_domain: RegionDomain::new(), upstream_data: Upstream::default(), + text_on_path_metadata: None, } } } @@ -61,7 +94,7 @@ impl std::hash::Hash for Vector { self.region_domain.hash(state); self.style.hash(state); self.colinear_manipulators.hash(state); - // We don't hash the upstream_data intentionally + // We don't hash upstream_data or text_on_path_metadata intentionally } } diff --git a/node-graph/nodes/gstd/src/text.rs b/node-graph/nodes/gstd/src/text.rs index 4cb280ba30..a1f93c1b07 100644 --- a/node-graph/nodes/gstd/src/text.rs +++ b/node-graph/nodes/gstd/src/text.rs @@ -1,6 +1,7 @@ use core_types::{Ctx, table::Table}; use graph_craft::wasm_application_io::WasmEditorApi; use graphic_types::Vector; +pub use text_nodes::text_on_path::{LengthAdjust, TextAnchor, TextPathMethod, TextPathSide, TextPathSpacing}; pub use text_nodes::*; /// Draws a text string as vector geometry with a choice of font and styling. @@ -74,3 +75,77 @@ fn text<'i: 'n>( to_path(&text, &font, &editor_resources.font_cache, typesetting, separate_glyph_elements) } + +/// 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<'i: 'n>( + _: impl Ctx, + #[scope("editor-api")] editor_resources: &'i WasmEditorApi, + /// The text content to flow along the path. + #[default("Lorem ipsum")] + text: String, + /// The vector path that glyphs follow. + path: Table, + /// The typeface used to draw the text. + font: Font, + /// The font size in pixels. + #[unit(" px")] + #[default(24.)] + #[hard_min(1.)] + size: f64, + /// Additional spacing, in pixels, added between each character. + #[unit(" px")] + #[step(0.1)] + character_spacing: f64, + /// Arc-length offset from the path start to the first glyph. + #[unit(" px")] + start_offset: f64, + /// If true, start_offset is treated as a 0–1 fraction of total path length. + start_offset_percent: bool, + /// Which side of the path direction to place text. + side: text_nodes::text_on_path::TextPathSide, + /// Text anchor point — affects where along the path the text is anchored. + text_anchor: text_nodes::text_on_path::TextAnchor, + /// Glyph rendering method. 'Align' uses rigid transforms; 'Stretch' warps glyphs along the path curvature. + method: text_nodes::text_on_path::TextPathMethod, + /// Spacing mode. 'Exact' uses computed positions; 'Auto' adjusts for path curvature. + spacing: text_nodes::text_on_path::TextPathSpacing, + /// Whether a forced text length is enabled. + #[widget(ParsedWidgetOverride::Hidden)] + has_text_length: bool, + /// If set, forces the total text advance to this length along the path. + #[unit(" px")] + #[hard_min(0.)] + text_length: f64, + /// How to fit text to the forced text length: adjust spacing only, or spacing and glyph widths. + length_adjust: text_nodes::text_on_path::LengthAdjust, + /// Whether a custom path authoring length is enabled. + #[widget(ParsedWidgetOverride::Hidden)] + has_path_length: bool, + /// Authoring path length for scaling startOffset. Maps the offset to the actual path length. + #[unit(" px")] + #[hard_min(0.)] + path_length: f64, + /// Right-to-left text direction. + rtl: bool, +) -> Table { + text_nodes::text_on_path::place_text_on_path( + &text, + &path, + &font, + size, + character_spacing, + start_offset, + start_offset_percent, + side, + text_anchor, + method, + spacing, + has_text_length.then_some(text_length), + length_adjust, + has_path_length.then_some(path_length), + rtl, + &editor_resources.font_cache, + ) +} + diff --git a/node-graph/nodes/text/Cargo.toml b/node-graph/nodes/text/Cargo.toml index 4537425a80..9b455bf6ef 100644 --- a/node-graph/nodes/text/Cargo.toml +++ b/node-graph/nodes/text/Cargo.toml @@ -21,6 +21,7 @@ dyn-any = { workspace = true } glam = { workspace = true } parley = { workspace = true } skrifa = { workspace = true } +kurbo = { workspace = true } log = { workspace = true } # Optional workspace dependencies diff --git a/node-graph/nodes/text/src/font_cache.rs b/node-graph/nodes/text/src/font_cache.rs index 58111bda21..936f863641 100644 --- a/node-graph/nodes/text/src/font_cache.rs +++ b/node-graph/nodes/text/src/font_cache.rs @@ -1,6 +1,8 @@ use dyn_any::DynAny; use parley::fontique::Blob; +use serde::Deserialize; use std::collections::HashMap; +use std::hash::Hash; use std::sync::Arc; /// A font type (storing font family and font style and an optional preview URL) @@ -19,13 +21,11 @@ impl std::hash::Hash for Font { fn hash(&self, state: &mut H) { self.font_family.hash(state); self.font_style.hash(state); - // Don't consider `font_style_to_restore` in the HashMaps } } impl PartialEq for Font { fn eq(&self, other: &Self) -> bool { - // Don't consider `font_style_to_restore` in the HashMaps self.font_family == other.font_family && self.font_style == other.font_style } } @@ -40,7 +40,6 @@ impl Font { } pub fn named_weight(weight: u32) -> &'static str { - // From https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight#common_weight_name_mapping match weight { 100 => "Thin", 200 => "Extra Light", @@ -51,81 +50,97 @@ impl Font { 700 => "Bold", 800 => "Extra Bold", 900 => "Black", - 950 => "Extra Black", - _ => "Regular", + _ => "Weight", } } } + impl Default for Font { fn default() -> Self { - Self::new(core_types::consts::DEFAULT_FONT_FAMILY.into(), core_types::consts::DEFAULT_FONT_STYLE.into()) + Self { + font_family: core_types::consts::DEFAULT_FONT_FAMILY.into(), + font_style: core_types::consts::DEFAULT_FONT_STYLE.into(), + font_style_to_restore: None, + } } } -/// A cache of all loaded font data and preview urls along with the default font (send from `init_app` in `editor_api.rs`) -#[derive(Clone, serde::Serialize, serde::Deserialize, Default, DynAny)] +/// A cache of fonts +#[derive(Debug, Default, Clone, PartialEq, serde::Serialize, serde::Deserialize, DynAny)] pub struct FontCache { - /// Actual font file data used for rendering a font - font_file_data: HashMap>, + /// Mapping of font family name to font style name to font data + pub font_file_data: HashMap>>, } -impl std::fmt::Debug for FontCache { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("FontCache").field("font_file_data", &self.font_file_data.keys().collect::>()).finish() - } -} - -impl std::hash::Hash for FontCache { +impl Hash for FontCache { fn hash(&self, state: &mut H) { self.font_file_data.len().hash(state); - self.font_file_data.keys().for_each(|font| font.hash(state)); } } -impl PartialEq for FontCache { - fn eq(&self, other: &Self) -> bool { - if self.font_file_data.len() != other.font_file_data.len() { - return false; - } - self.font_file_data.keys().all(|font| other.font_file_data.contains_key(font)) +impl FontCache { + /// Get the font data for a font + pub fn get_data(&self, font: &Font) -> Option>> { + self.font_file_data.get(font).cloned() + } + + /// Insert font data for a font + pub fn insert(&mut self, font: Font, data: impl Into>>) { + self.font_file_data.insert(font, data.into()); + } + + /// Check if the font data for a font is cached + pub fn loaded_font(&self, font: &Font) -> bool { + self.font_file_data.contains_key(font) + } + + /// Check if the font data for a font is cached + pub fn has(&self, font: &Font) -> bool { + self.font_file_data.contains_key(font) + } + + /// Get the number of fonts in the cache + pub fn len(&self) -> usize { + self.font_file_data.len() + } + + /// Check if the cache is empty + pub fn is_empty(&self) -> bool { + self.font_file_data.is_empty() + } + + /// Get an iterator over the fonts in the cache + pub fn fonts(&self) -> impl Iterator { + self.font_file_data.keys() } -} -impl FontCache { /// Returns the font family name if the font is cached, otherwise returns the fallback font family name if that is cached pub fn resolve_font<'a>(&'a self, font: &'a Font) -> Option<&'a Font> { if self.font_file_data.contains_key(font) { Some(font) } else { - self.font_file_data + let fallback = self + .font_file_data .keys() .find(|font| font.font_family == core_types::consts::DEFAULT_FONT_FAMILY && font.font_style == core_types::consts::DEFAULT_FONT_STYLE) + .or_else(|| self.font_file_data.keys().next()); + fallback } } /// Try to get the bytes for a font pub fn get<'a>(&'a self, font: &'a Font) -> Option<(&'a Vec, &'a Font)> { - self.resolve_font(font).and_then(|font| self.font_file_data.get(font).map(|data| (data, font))) + let resolved = self.resolve_font(font)?; + self.font_file_data.get(resolved).map(|data| (data.as_ref(), resolved)) } - /// Get font data as a Blob for use with parley/skrifa pub fn get_blob<'a>(&'a self, font: &'a Font) -> Option<(Blob, &'a Font)> { - self.get(font).map(|(data, font)| (Blob::new(Arc::new(data.clone())), font)) - } - - /// Check if the font is already loaded - pub fn loaded_font(&self, font: &Font) -> bool { - self.font_file_data.contains_key(font) - } - - /// Insert a new font into the cache - pub fn insert(&mut self, font: Font, data: Vec) { - self.font_file_data.insert(font.clone(), data); + let resolved = self.resolve_font(font)?; + self.font_file_data.get(resolved).map(|data| (Blob::new(data.clone()), resolved)) } } // TODO: Eventually remove this migration document upgrade code fn migrate_font_style<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result { - use serde::Deserialize; String::deserialize(deserializer).map(|name| if name == "Normal (400)" { "Regular (400)".to_string() } else { name }) } diff --git a/node-graph/nodes/text/src/lib.rs b/node-graph/nodes/text/src/lib.rs index ca4738ffa1..a2348ac115 100644 --- a/node-graph/nodes/text/src/lib.rs +++ b/node-graph/nodes/text/src/lib.rs @@ -1,6 +1,7 @@ mod font_cache; mod path_builder; mod text_context; +pub mod text_on_path; mod to_path; use dyn_any::DynAny; diff --git a/node-graph/nodes/text/src/path_builder.rs b/node-graph/nodes/text/src/path_builder.rs index c5ba250409..82cdec4419 100644 --- a/node-graph/nodes/text/src/path_builder.rs +++ b/node-graph/nodes/text/src/path_builder.rs @@ -1,126 +1,110 @@ use core_types::table::{Table, TableRow}; use glam::{DAffine2, DVec2}; -use parley::GlyphRun; -use skrifa::GlyphId; -use skrifa::instance::{LocationRef, NormalizedCoord, Size}; -use skrifa::outline::{DrawSettings, OutlinePen}; +use kurbo::{PathSeg, Point}; +use skrifa::instance::{NormalizedCoord, Size}; +use skrifa::outline::{DrawSettings, OutlineGlyph, OutlinePen}; use skrifa::raw::FontRef as ReadFontsRef; -use skrifa::{MetadataProvider, OutlineGlyph}; -use vector_types::subpath::{ManipulatorGroup, Subpath}; -use vector_types::vector::{PointId, Vector}; - -pub struct PathBuilder { - current_subpath: Subpath, - origin: DVec2, - glyph_subpaths: Vec>, - pub vector_table: Table>, +use skrifa::{GlyphId, MetadataProvider}; +use vector_types::{Subpath, Vector}; + +pub struct PathBuilder { + vector_table: Table>, + current_segments: Vec, + glyph_subpaths: Vec>, + current_point: Point, + is_text_on_path: bool, scale: f64, - id: PointId, } impl PathBuilder { - pub fn new(per_glyph_instances: bool, scale: f64) -> Self { + pub fn new(is_text_on_path: bool, scale: f64) -> Self { Self { - current_subpath: Subpath::new(Vec::new(), false), + vector_table: Table::new(), + current_segments: Vec::new(), glyph_subpaths: Vec::new(), - vector_table: if per_glyph_instances { Table::new() } else { Table::new_from_element(Vector::default()) }, + current_point: Point::ZERO, + is_text_on_path, scale, - id: PointId::ZERO, - origin: DVec2::default(), } } - fn point(&self, x: f32, y: f32) -> DVec2 { - DVec2::new(self.origin.x + x as f64, self.origin.y - y as f64) * self.scale + fn point(&self, x: f32, y: f32) -> Point { + Point::new(x as f64, -y as f64) } - #[allow(clippy::too_many_arguments)] - fn draw_glyph(&mut self, glyph: &OutlineGlyph<'_>, size: f32, normalized_coords: &[NormalizedCoord], glyph_offset: DVec2, style_skew: Option, skew: DAffine2, per_glyph_instances: bool) { - let location_ref = LocationRef::new(normalized_coords); - let settings = DrawSettings::unhinted(Size::new(size), location_ref); - glyph.draw(settings, self).unwrap(); + pub fn draw_glyph(&mut self, glyph: &OutlineGlyph<'_>, size: f32, normalized_coords: &[NormalizedCoord], style_skew: Option, final_transform: DAffine2, per_glyph_instances: bool) { + self.glyph_subpaths.clear(); + self.current_segments.clear(); + self.current_point = Point::ZERO; - // Apply transforms in correct order: style-based skew first, then user-requested skew - // This ensures font synthesis (italic) is applied before user transformations - for glyph_subpath in &mut self.glyph_subpaths { - if let Some(style_skew) = style_skew { - glyph_subpath.apply_transform(style_skew); - } + let settings = DrawSettings::unhinted(Size::new(size), normalized_coords); + glyph.draw(settings, self).unwrap(); - glyph_subpath.apply_transform(skew); + if !self.current_segments.is_empty() { + self.glyph_subpaths.push(Subpath::from_beziers(&self.current_segments, false)); + self.current_segments.clear(); } + let transform = if self.is_text_on_path { + final_transform + } else { + final_transform * DAffine2::from_scale(DVec2::splat(self.scale)) + }; + let transform = if let Some(skew) = style_skew { transform * skew } else { transform }; + + let subpaths = std::mem::take(&mut self.glyph_subpaths); if per_glyph_instances { - self.vector_table.push(TableRow { - element: Vector::from_subpaths(core::mem::take(&mut self.glyph_subpaths), false), - transform: DAffine2::from_translation(glyph_offset), - ..Default::default() - }); + let mut vector = Vector::from_subpaths(subpaths, false); + vector.transform(transform); + self.vector_table.push(TableRow::new_from_element(vector)); } else { - for subpath in self.glyph_subpaths.drain(..) { - // Unwrapping here is ok because `self.vector_table` is initialized with a single `Vector` table element - self.vector_table.get_mut(0).unwrap().element.append_subpath(subpath, false); + let mut vector = Vector::from_subpaths(subpaths, false); + vector.transform(transform); + if self.vector_table.is_empty() { + self.vector_table = Table::new_from_element(vector); + } else { + let current_vector = self.vector_table.iter_mut().next().unwrap(); + current_vector.element.concat(&vector, DAffine2::IDENTITY, 0); } } } - pub fn render_glyph_run(&mut self, glyph_run: &GlyphRun<'_, ()>, tilt: f64, per_glyph_instances: bool) { + pub fn render_glyph_run(&mut self, glyph_run: &parley::GlyphRun<'_, ()>, tilt: f64, per_glyph_instances: bool) { + let run = glyph_run.run(); let mut run_x = glyph_run.offset(); let run_y = glyph_run.baseline(); - let run = glyph_run.run(); - - // User-requested tilt applied around baseline to avoid vertical displacement - // Translation ensures rotation point is at the baseline, not origin - let skew = if per_glyph_instances { - DAffine2::from_cols_array(&[1., 0., -tilt.to_radians().tan(), 1., 0., 0.]) - } else { - DAffine2::from_translation(DVec2::new(0., run_y as f64)) - * DAffine2::from_cols_array(&[1., 0., -tilt.to_radians().tan(), 1., 0., 0.]) - * DAffine2::from_translation(DVec2::new(0., -run_y as f64)) - }; - let synthesis = run.synthesis(); - - // Font synthesis (e.g., synthetic italic) applied separately from user transforms - // This preserves the distinction between font styling and user transformations - let style_skew = synthesis.skew().map(|angle| { - if per_glyph_instances { - DAffine2::from_cols_array(&[1., 0., -angle.to_radians().tan() as f64, 1., 0., 0.]) - } else { - DAffine2::from_translation(DVec2::new(0., run_y as f64)) - * DAffine2::from_cols_array(&[1., 0., -angle.to_radians().tan() as f64, 1., 0., 0.]) - * DAffine2::from_translation(DVec2::new(0., -run_y as f64)) - } - }); + let style_skew = synthesis.skew().map(|angle| DAffine2::from_cols_array(&[1., 0., -(angle as f64).to_radians().tan(), 1., 0., 0.])); + let tilt_skew = (tilt != 0.).then(|| DAffine2::from_cols_array(&[1., 0., -tilt.to_radians().tan(), 1., 0., 0.])); let font = run.font(); let font_size = run.font_size(); let normalized_coords = run.normalized_coords().iter().map(|coord| NormalizedCoord::from_bits(*coord)).collect::>(); - // TODO: This can be cached for better performance let font_collection_ref = font.data.as_ref(); let font_ref = ReadFontsRef::from_index(font_collection_ref, font.index).unwrap(); let outlines = font_ref.outline_glyphs(); - for glyph in glyph_run.glyphs() { + glyph_run.glyphs().for_each(|glyph| { let glyph_offset = DVec2::new((run_x + glyph.x) as f64, (run_y - glyph.y) as f64); run_x += glyph.advance; - let glyph_id = GlyphId::from(glyph.id); - if let Some(glyph_outline) = outlines.get(glyph_id) { - if !per_glyph_instances { - self.origin = glyph_offset; + if let Some(glyph_outline) = outlines.get(GlyphId::from(glyph.id)) { + let mut final_transform = DAffine2::from_translation(glyph_offset); + if let Some(tilt_skew) = tilt_skew { + final_transform = final_transform * tilt_skew; } - self.draw_glyph(&glyph_outline, font_size, &normalized_coords, glyph_offset, style_skew, skew, per_glyph_instances); + + self.draw_glyph(&glyph_outline, font_size, &normalized_coords, style_skew, final_transform, per_glyph_instances); } - } + }); } pub fn finalize(mut self) -> Table> { if self.vector_table.is_empty() { - self.vector_table = Table::new_from_element(Vector::default()); + self.vector_table = Table::new_from_element(Vector::default()) } self.vector_table } @@ -128,31 +112,38 @@ impl PathBuilder { impl OutlinePen for PathBuilder { fn move_to(&mut self, x: f32, y: f32) { - if !self.current_subpath.is_empty() { - self.glyph_subpaths.push(std::mem::replace(&mut self.current_subpath, Subpath::new(Vec::new(), false))); + if !self.current_segments.is_empty() { + self.glyph_subpaths.push(Subpath::from_beziers(&self.current_segments, false)); + self.current_segments.clear(); } - self.current_subpath.push_manipulator_group(ManipulatorGroup::new_anchor_with_id(self.point(x, y), self.id.next_id())); + self.current_point = self.point(x, y); } fn line_to(&mut self, x: f32, y: f32) { - self.current_subpath.push_manipulator_group(ManipulatorGroup::new_anchor_with_id(self.point(x, y), self.id.next_id())); + let p = self.point(x, y); + self.current_segments.push(PathSeg::Line(kurbo::Line::new(self.current_point, p))); + self.current_point = p; } - fn quad_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32) { - let [handle, anchor] = [self.point(x1, y1), self.point(x2, y2)]; - self.current_subpath.last_manipulator_group_mut().unwrap().out_handle = Some(handle); - self.current_subpath.push_manipulator_group(ManipulatorGroup::new_with_id(anchor, None, None, self.id.next_id())); + fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) { + let p1 = self.point(cx0, cy0); + let p2 = self.point(x, y); + self.current_segments.push(PathSeg::Quad(kurbo::QuadBez::new(self.current_point, p1, p2))); + self.current_point = p2; } - fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x3: f32, y3: f32) { - let [handle1, handle2, anchor] = [self.point(x1, y1), self.point(x2, y2), self.point(x3, y3)]; - self.current_subpath.last_manipulator_group_mut().unwrap().out_handle = Some(handle1); - self.current_subpath - .push_manipulator_group(ManipulatorGroup::new_with_id(anchor, Some(handle2), None, self.id.next_id())); + fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) { + let p1 = self.point(cx0, cy0); + let p2 = self.point(cx1, cy1); + let p3 = self.point(x, y); + self.current_segments.push(PathSeg::Cubic(kurbo::CubicBez::new(self.current_point, p1, p2, p3))); + self.current_point = p3; } fn close(&mut self) { - self.current_subpath.set_closed(true); - self.glyph_subpaths.push(std::mem::replace(&mut self.current_subpath, Subpath::new(Vec::new(), false))); + if !self.current_segments.is_empty() { + self.glyph_subpaths.push(Subpath::from_beziers(&self.current_segments, true)); + self.current_segments.clear(); + } } } diff --git a/node-graph/nodes/text/src/text_context.rs b/node-graph/nodes/text/src/text_context.rs index 7934feb885..9bf16261a3 100644 --- a/node-graph/nodes/text/src/text_context.rs +++ b/node-graph/nodes/text/src/text_context.rs @@ -61,7 +61,7 @@ impl TextContext { } /// Create a text layout using the specified font and typesetting configuration - fn layout_text(&mut self, text: &str, font: &Font, font_cache: &FontCache, typesetting: TypesettingConfig) -> Option> { + pub(crate) fn layout_text(&mut self, text: &str, font: &Font, font_cache: &FontCache, typesetting: TypesettingConfig) -> Option> { // Note that the actual_font may not be the desired font if that font is not yet loaded. // It is important not to cache the default font under the name of another font. let (font_data, actual_font) = self.resolve_font_data(font, font_cache)?; 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..54b7eb2caa --- /dev/null +++ b/node-graph/nodes/text/src/text_on_path.rs @@ -0,0 +1,347 @@ +use core_types::table::Table; +use dyn_any::DynAny; +use glam::{DAffine2, DVec2}; +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)] +pub enum TextPathSide { + #[default] + Left, + Right, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Hash, serde::Serialize, serde::Deserialize, DynAny, node_macro::ChoiceType)] +pub enum TextAnchor { + #[default] + Start, + Middle, + End, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Hash, serde::Serialize, serde::Deserialize, DynAny, node_macro::ChoiceType)] +pub enum TextPathMethod { + #[default] + Align, + Stretch, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Hash, serde::Serialize, serde::Deserialize, DynAny, node_macro::ChoiceType)] +pub enum TextPathSpacing { + #[default] + Exact, + Auto, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Hash, serde::Serialize, serde::Deserialize, DynAny, node_macro::ChoiceType)] +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 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 mid < 0.0 || mid > total_length; } + let d = mid - start_offset; + let effective_anchor = if rtl { match text_anchor { TextAnchor::Start => TextAnchor::End, TextAnchor::End => TextAnchor::Start, _ => text_anchor } } else { text_anchor }; + match effective_anchor { + TextAnchor::Start => d < 0.0 || d > total_length, + TextAnchor::Middle => d < -total_length / 2.0 || d > total_length / 2.0, + TextAnchor::End => d < -total_length || d > 0.0, + } +} + +fn resolve_startpoint(abs_offset: f64, total_advance: f64, text_anchor: TextAnchor) -> f64 { + 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); + advance * (a1 - a0).abs() * 0.1 +} + +#[allow(clippy::too_many_arguments)] +pub fn place_text_on_path( + text: &str, + path_table: &Table>, + font: &crate::Font, + 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, + font_cache: &crate::FontCache, +) -> Table> { + // TODO: Support method="stretch" (warp glyph outlines along path perpendiculars) + if method == TextPathMethod::Stretch { + log::warn!("textPath method='stretch' is not yet implemented; falling back to 'align'"); + } + + let Some(original_bezpath) = path_table.iter().next().and_then(|row| row.element.stroke_bezpath_iter().find(|p| p.segments().next().is_some())) else { return Table::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 Table::new(); } + + let typesetting = crate::TypesettingConfig { + font_size, + character_spacing, + ..crate::TypesettingConfig::default() + }; + + let layout = crate::TextContext::with_thread_local(|ctx| ctx.layout_text(text, font, font_cache, typesetting)); + let Some(layout) = layout else { return Table::new() }; + + let mut abs_offset = if start_offset_percent { start_offset * lut.total_length } else { start_offset }; + if let Some(author_length) = path_length.filter(|&l| l > 1e-9) { abs_offset *= lut.total_length / author_length; } + + let mut path_builder = crate::path_builder::PathBuilder::new(true, layout.scale() as f64); + + 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); + + 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 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(font.data.as_ref(), font.index) else { return }; + let outlines = font_ref.outline_glyphs(); + + glyph_run.glyphs().for_each(|glyph| { + let raw_advance = glyph.advance as f64 * advance_scale; + cumulative_offset += if glyph_index > 0 { spacing_delta } else { 0.0 }; + let mid = line_start + run_x as f64 * advance_scale + cumulative_offset - glyph_run.offset() as f64 * advance_scale + raw_advance / 2.0; + let adjusted_mid = mid + if spacing == TextPathSpacing::Auto { curvature_spacing_adjustment(&lut, mid, raw_advance) } else { 0.0 }; + + run_x += glyph.advance; + glyph_index += 1; + + if !is_glyph_hidden(adjusted_mid, abs_offset, lut.total_length, lut.is_closed, text_anchor, rtl) { + let effective_mid = if lut.is_closed { adjusted_mid.rem_euclid(lut.total_length) } else { adjusted_mid }; + let (point, angle) = if lut.is_closed { lut.at_or_zero(effective_mid) } else { at_with_extension(&lut, effective_mid) }; + + if let Some(glyph_outline) = outlines.get(skrifa::GlyphId::from(glyph.id)) { + let final_transform = DAffine2::from_translation(DVec2::new(point.x, point.y)) + * DAffine2::from_angle(angle) + * DAffine2::from_translation(DVec2::new(glyph.x as f64, -glyph.y as f64)); + path_builder.draw_glyph(&glyph_outline, font_size, &normalized_coords, style_skew, final_transform, true); + } + } + + cumulative_offset += raw_advance - glyph.advance as f64; + }); + } + }); + }); + + let mut result = path_builder.finalize(); + + // Attach text-on-path metadata so SVG export can emit instead of raw outlines + let metadata = Arc::new(TextOnPathMetadata { + text: text.to_string(), + font_family: font.font_family.clone(), + font_style: font.font_style.clone(), + 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(), + }); + for row in result.iter_mut() { + row.element.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 40fb786b06..535296f598 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -1343,6 +1343,7 @@ async fn sample_polyline( colinear_manipulators: Default::default(), style: std::mem::take(&mut row.element.style), upstream_data: std::mem::take(&mut row.element.upstream_data), + ..Default::default() }; // Transfer the stroke transform from the input vector content to the result. result.style.set_stroke_transform(row.transform); From c75cc64736e809359114d6d9e7b7841018318796 Mon Sep 17 00:00:00 2001 From: Kulratan Date: Mon, 4 May 2026 15:39:47 +0000 Subject: [PATCH 2/5] Fix --- .../graph_operation_message_handler.rs | 118 ++++++++++++++---- .../src/vector/vector_attributes.rs | 1 + .../vector-types/src/vector/vector_types.rs | 9 +- node-graph/nodes/text/src/font_cache.rs | 3 +- node-graph/nodes/text/src/path_builder.rs | 5 +- node-graph/nodes/text/src/text_on_path.rs | 85 +++++++++---- 6 files changed, 167 insertions(+), 54 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 f875da6cec..59b96f5b11 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 @@ -394,8 +394,11 @@ impl MessageHandler> for center, } => { let mut options = usvg::Options::default(); - options.fontdb_mut().load_font_data(include_bytes!("../overlays/source-sans-pro-regular.ttf").to_vec()); options.font_family = "Source Sans Pro".to_string(); + let fontdb = options.fontdb_mut(); + fontdb.load_font_data(include_bytes!("../overlays/source-sans-pro-regular.ttf").to_vec()); + fontdb.set_serif_family("Source Sans Pro"); + fontdb.set_sans_serif_family("Source Sans Pro"); 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'"); @@ -473,6 +476,7 @@ fn usvg_transform(c: usvg::Transform) -> DAffine2 { } const GRAPHITE_NAMESPACE: &str = "https://graphite.art"; +const XLINK_NAMESPACE: &str = "http://www.w3.org/1999/xlink"; /// Pre-parses the raw SVG XML to extract gradient stops that have `graphite:midpoint` attributes. /// Graphite exports gradients with midpoint curve data by writing interpolated approximation stops @@ -559,8 +563,10 @@ fn pre_parse_textpath_attrs(svg: &str) -> std::collections::HashMap std::collections::HashMap 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 @@ -625,12 +638,14 @@ fn import_usvg_node( modify_inputs.network_interface.unload_all_nodes_bounding_box(&[]); } usvg::Node::Path(path) => { + log::info!("Importing node as Path: id={}", node.id()); import_usvg_path(modify_inputs, node, path, layer, graphite_gradient_stops); } usvg::Node::Image(_image) => { warn!("Skip image"); } 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); } } @@ -711,8 +726,16 @@ 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>) { - use graphene_std::text::{LengthAdjust, TextAnchor, TextPathMethod, TextPathSide, TextPathSpacing}; +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()); for (i, chunk) in text.chunks().iter().enumerate() { let current_layer = if i == 0 { @@ -738,38 +761,79 @@ fn import_usvg_text(modify_inputs: &mut ModifyInputsContext, text: &usvg::Text, if let usvg::TextFlow::Path(text_path) = chunk.text_flow() { let tp_id = text_path.id(); - let tp_attrs = textpath_attrs.get_mut(tp_id).and_then(|vec| if !vec.is_empty() { Some(vec.remove(0)) } else { None }).unwrap_or_default(); + let tp_attrs = take_textpath_attrs(textpath_attrs, tp_id); let path_subpaths = convert_tiny_skia_path(text_path.path()); let start_offset = text_path.start_offset() as f64; - let anchor = match chunk.anchor() { - usvg::TextAnchor::Start => TextAnchor::Start, - usvg::TextAnchor::Middle => TextAnchor::Middle, - usvg::TextAnchor::End => TextAnchor::End, - }; - let affine = DAffine2::from_cols_array(&[ - transform.sx as f64, - transform.ky as f64, - transform.kx as f64, - transform.sy as f64, - transform.tx as f64, - transform.ty as f64, - ]); - let method = if tp_attrs.method.as_deref() == Some("stretch") { TextPathMethod::Stretch } else { TextPathMethod::Align }; - let spacing = if tp_attrs.spacing.as_deref() == Some("auto") { TextPathSpacing::Auto } else { TextPathSpacing::Exact }; - let side = if tp_attrs.side.as_deref() == Some("right") { TextPathSide::Right } else { TextPathSide::Left }; - let length_adjust = if tp_attrs.length_adjust.as_deref() == Some("spacingAndGlyphs") { LengthAdjust::SpacingAndGlyphs } else { LengthAdjust::Spacing }; - - modify_inputs.insert_text_on_path(chunk.text().to_string(), font, font_size, letter_spacing, path_subpaths, start_offset, anchor, side, method, spacing, tp_attrs.text_length, length_adjust, affine, current_layer); - modify_inputs.fill_set(Fill::Solid(Color::BLACK)); + modify_inputs.insert_text_on_path( + chunk.text().to_string(), + font, + font_size, + letter_spacing, + path_subpaths, + start_offset, + 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), + usvg_transform(transform), + current_layer, + ); + if let Some(fill) = chunk.spans().first().and_then(|span| span.fill()) { + apply_usvg_fill(fill, modify_inputs, DAffine2::IDENTITY, &HashMap::new()); + } } else { // Regular text fallback modify_inputs.insert_text(chunk.text().to_string(), font, TypesettingConfig { font_size, ..Default::default() }, current_layer); - modify_inputs.fill_set(Fill::Solid(Color::BLACK)); + if let Some(fill) = chunk.spans().first().and_then(|span| span.fill()) { + apply_usvg_fill(fill, modify_inputs, DAffine2::IDENTITY, &HashMap::new()); + } } } } +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/node-graph/libraries/vector-types/src/vector/vector_attributes.rs b/node-graph/libraries/vector-types/src/vector/vector_attributes.rs index a2f8edd188..3d99b61005 100644 --- a/node-graph/libraries/vector-types/src/vector/vector_attributes.rs +++ b/node-graph/libraries/vector-types/src/vector/vector_attributes.rs @@ -1207,6 +1207,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 13842dfc3b..7ba6be9a1d 100644 --- a/node-graph/libraries/vector-types/src/vector/vector_types.rs +++ b/node-graph/libraries/vector-types/src/vector/vector_types.rs @@ -94,13 +94,20 @@ impl std::hash::Hash for Vector { self.region_domain.hash(state); self.style.hash(state); self.colinear_manipulators.hash(state); - // We don't hash upstream_data or text_on_path_metadata intentionally + if let Some(metadata) = &self.text_on_path_metadata { + metadata.text.hash(state); + metadata.font_family.hash(state); + metadata.font_style.hash(state); + (metadata.font_size as u64).hash(state); + metadata.path_d.hash(state); + } } } 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/text/src/font_cache.rs b/node-graph/nodes/text/src/font_cache.rs index 936f863641..2ca6581a8e 100644 --- a/node-graph/nodes/text/src/font_cache.rs +++ b/node-graph/nodes/text/src/font_cache.rs @@ -122,8 +122,7 @@ impl FontCache { let fallback = self .font_file_data .keys() - .find(|font| font.font_family == core_types::consts::DEFAULT_FONT_FAMILY && font.font_style == core_types::consts::DEFAULT_FONT_STYLE) - .or_else(|| self.font_file_data.keys().next()); + .find(|font| font.font_family == core_types::consts::DEFAULT_FONT_FAMILY && font.font_style == core_types::consts::DEFAULT_FONT_STYLE); fallback } } diff --git a/node-graph/nodes/text/src/path_builder.rs b/node-graph/nodes/text/src/path_builder.rs index 82cdec4419..6ceb7a0b42 100644 --- a/node-graph/nodes/text/src/path_builder.rs +++ b/node-graph/nodes/text/src/path_builder.rs @@ -38,7 +38,10 @@ impl PathBuilder { self.current_point = Point::ZERO; let settings = DrawSettings::unhinted(Size::new(size), normalized_coords); - glyph.draw(settings, self).unwrap(); + if let Err(e) = glyph.draw(settings, self) { + log::error!("Failed to draw glyph: {:?}", e); + return; + } if !self.current_segments.is_empty() { self.glyph_subpaths.push(Subpath::from_beziers(&self.current_segments, false)); diff --git a/node-graph/nodes/text/src/text_on_path.rs b/node-graph/nodes/text/src/text_on_path.rs index 54b7eb2caa..cecd6e815b 100644 --- a/node-graph/nodes/text/src/text_on_path.rs +++ b/node-graph/nodes/text/src/text_on_path.rs @@ -196,26 +196,34 @@ fn maybe_reverse_path(path: BezPath, side: TextPathSide) -> BezPath { } } -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 mid < 0.0 || mid > total_length; } - let d = mid - start_offset; - let effective_anchor = if rtl { match text_anchor { TextAnchor::Start => TextAnchor::End, TextAnchor::End => TextAnchor::Start, _ => text_anchor } } else { text_anchor }; - match effective_anchor { - TextAnchor::Start => d < 0.0 || d > total_length, - TextAnchor::Middle => d < -total_length / 2.0 || d > total_length / 2.0, - TextAnchor::End => d < -total_length || d > 0.0, +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) -> f64 { - match text_anchor { TextAnchor::Start => abs_offset, TextAnchor::Middle => abs_offset - total_advance / 2.0, TextAnchor::End => abs_offset - total_advance } + 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); - advance * (a1 - a0).abs() * 0.1 + 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), + } } #[allow(clippy::too_many_arguments)] @@ -242,12 +250,16 @@ pub fn place_text_on_path( log::warn!("textPath method='stretch' is not yet implemented; falling back to 'align'"); } - let Some(original_bezpath) = path_table.iter().next().and_then(|row| row.element.stroke_bezpath_iter().find(|p| p.segments().next().is_some())) else { return Table::new() }; + let Some(original_bezpath) = path_table.iter().next().and_then(|row| row.element.stroke_bezpath_iter().find(|p| p.segments().next().is_some())) else { + return Table::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 Table::new(); } + if lut.total_length < 1e-9 { + return Table::new(); + } let typesetting = crate::TypesettingConfig { font_size, @@ -256,10 +268,17 @@ pub fn place_text_on_path( }; let layout = crate::TextContext::with_thread_local(|ctx| ctx.layout_text(text, font, font_cache, typesetting)); - let Some(layout) = layout else { return Table::new() }; + let Some(layout) = layout else { + log::error!("Text layout failed for: {}", text); + return Table::new(); + }; + + log::info!("Placing text on path: {} (length: {})", text, lut.total_length); let mut abs_offset = if start_offset_percent { start_offset * lut.total_length } else { start_offset }; - if let Some(author_length) = path_length.filter(|&l| l > 1e-9) { abs_offset *= lut.total_length / author_length; } + if let Some(author_length) = path_length.filter(|&l| l > 1e-9) { + abs_offset *= lut.total_length / author_length; + } let mut path_builder = crate::path_builder::PathBuilder::new(true, layout.scale() as f64); @@ -298,7 +317,7 @@ pub fn place_text_on_path( let raw_advance = glyph.advance as f64 * advance_scale; cumulative_offset += if glyph_index > 0 { spacing_delta } else { 0.0 }; let mid = line_start + run_x as f64 * advance_scale + cumulative_offset - glyph_run.offset() as f64 * advance_scale + raw_advance / 2.0; - let adjusted_mid = mid + if spacing == TextPathSpacing::Auto { curvature_spacing_adjustment(&lut, mid, raw_advance) } else { 0.0 }; + let adjusted_mid = mid + text_path_spacing_adjustment(spacing, &lut, mid, raw_advance); run_x += glyph.advance; glyph_index += 1; @@ -308,9 +327,8 @@ pub fn place_text_on_path( let (point, angle) = if lut.is_closed { lut.at_or_zero(effective_mid) } else { at_with_extension(&lut, effective_mid) }; if let Some(glyph_outline) = outlines.get(skrifa::GlyphId::from(glyph.id)) { - let final_transform = DAffine2::from_translation(DVec2::new(point.x, point.y)) - * DAffine2::from_angle(angle) - * DAffine2::from_translation(DVec2::new(glyph.x as f64, -glyph.y as f64)); + let final_transform = + DAffine2::from_translation(DVec2::new(point.x, point.y)) * DAffine2::from_angle(angle) * DAffine2::from_translation(DVec2::new(glyph.x as f64, -glyph.y as f64)); path_builder.draw_glyph(&glyph_outline, font_size, &normalized_coords, style_skew, final_transform, true); } } @@ -332,12 +350,33 @@ pub fn place_text_on_path( 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_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(), + length_adjust: match length_adjust { + LengthAdjust::Spacing => "spacing", + LengthAdjust::SpacingAndGlyphs => "spacingAndGlyphs", + } + .to_string(), }); for row in result.iter_mut() { row.element.text_on_path_metadata = Some(Arc::clone(&metadata)); From d7c06c92dc72508b83bbd889967ea923935b6f8b Mon Sep 17 00:00:00 2001 From: Kulratan Date: Mon, 4 May 2026 17:34:58 +0000 Subject: [PATCH 3/5] Improvment --- node-graph/nodes/text/src/text_on_path.rs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/node-graph/nodes/text/src/text_on_path.rs b/node-graph/nodes/text/src/text_on_path.rs index cecd6e815b..0659f3499e 100644 --- a/node-graph/nodes/text/src/text_on_path.rs +++ b/node-graph/nodes/text/src/text_on_path.rs @@ -55,6 +55,7 @@ pub struct ArcLengthLut { 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; @@ -314,10 +315,11 @@ pub fn place_text_on_path( let outlines = font_ref.outline_glyphs(); glyph_run.glyphs().for_each(|glyph| { - let raw_advance = glyph.advance as f64 * advance_scale; + let scaled_advance = glyph.advance as f64 * advance_scale; cumulative_offset += if glyph_index > 0 { spacing_delta } else { 0.0 }; - let mid = line_start + run_x as f64 * advance_scale + cumulative_offset - glyph_run.offset() as f64 * advance_scale + raw_advance / 2.0; - let adjusted_mid = mid + text_path_spacing_adjustment(spacing, &lut, mid, raw_advance); + let glyph_origin = line_start + (run_x as f64 - glyph_run.offset() as f64 + glyph.x as f64) * advance_scale + cumulative_offset; + let mid = glyph_origin + scaled_advance / 2.0; + let adjusted_mid = mid + text_path_spacing_adjustment(spacing, &lut, mid, scaled_advance); run_x += glyph.advance; glyph_index += 1; @@ -327,13 +329,13 @@ pub fn place_text_on_path( let (point, angle) = if lut.is_closed { lut.at_or_zero(effective_mid) } else { at_with_extension(&lut, effective_mid) }; if let Some(glyph_outline) = outlines.get(skrifa::GlyphId::from(glyph.id)) { - let final_transform = - DAffine2::from_translation(DVec2::new(point.x, point.y)) * DAffine2::from_angle(angle) * DAffine2::from_translation(DVec2::new(glyph.x as f64, -glyph.y as f64)); + 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(&glyph_outline, font_size, &normalized_coords, style_skew, final_transform, true); } } - - cumulative_offset += raw_advance - glyph.advance as f64; }); } }); From 76c6f9dbbb8d17fa4b9e5a978e8f1fbecb1d3a44 Mon Sep 17 00:00:00 2001 From: Kulratan Date: Mon, 4 May 2026 17:42:57 +0000 Subject: [PATCH 4/5] method="stretch" and Fixes --- .../graph_operation_message_handler.rs | 71 +++++++++++++++++++ .../libraries/rendering/src/renderer.rs | 20 ++++-- node-graph/nodes/text/src/path_builder.rs | 36 +++++++++- node-graph/nodes/text/src/text_on_path.rs | 43 +++++++---- 4 files changed, 151 insertions(+), 19 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 59b96f5b11..8a89963fee 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 @@ -402,6 +402,7 @@ impl MessageHandler> for 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, @@ -546,6 +547,76 @@ fn parse_hex_stop_color(hex: &str, opacity: f32) -> Option { Some(Color::from_rgbaf32_unchecked(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 method: Option, diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 8145209a4a..e466b5b8f0 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -50,6 +50,14 @@ impl MaskType { } } +fn escape_xml_attr(value: &str) -> String { + value.replace('&', "&").replace('"', """).replace('<', "<").replace('>', ">") +} + +fn escape_xml_text(value: &str) -> String { + value.replace('&', "&").replace('<', "<").replace('>', ">") +} + /// Mutable state used whilst rendering to an SVG pub struct SvgRender { pub svg: Vec, @@ -790,10 +798,14 @@ impl Render for Table { if render_params.for_export { if let Some(ref meta) = row.element.text_on_path_metadata { let path_id = format!("textpath-{}", generate_uuid()); - write!(&mut render.svg_defs, r#""#, meta.path_d).unwrap(); + write!(&mut render.svg_defs, r#""#, escape_xml_attr(&meta.path_d)).unwrap(); - let font_style_css = 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 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(*row.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(); @@ -801,7 +813,7 @@ impl Render for Table { let anchor_style = format!("text-anchor: {};", meta.text_anchor); let method = &meta.method; let spacing = &meta.spacing; - let text = &meta.text; + let text = escape_xml_text(&meta.text); render.leaf_node(format!(r##"{text}"##)); continue; diff --git a/node-graph/nodes/text/src/path_builder.rs b/node-graph/nodes/text/src/path_builder.rs index 6ceb7a0b42..bfd3021f4f 100644 --- a/node-graph/nodes/text/src/path_builder.rs +++ b/node-graph/nodes/text/src/path_builder.rs @@ -32,7 +32,7 @@ impl PathBuilder { Point::new(x as f64, -y as f64) } - pub fn draw_glyph(&mut self, glyph: &OutlineGlyph<'_>, size: f32, normalized_coords: &[NormalizedCoord], style_skew: Option, final_transform: DAffine2, per_glyph_instances: bool) { + fn outline_glyph(&mut self, glyph: &OutlineGlyph<'_>, size: f32, normalized_coords: &[NormalizedCoord]) -> bool { self.glyph_subpaths.clear(); self.current_segments.clear(); self.current_point = Point::ZERO; @@ -40,7 +40,7 @@ impl PathBuilder { let settings = DrawSettings::unhinted(Size::new(size), normalized_coords); if let Err(e) = glyph.draw(settings, self) { log::error!("Failed to draw glyph: {:?}", e); - return; + return false; } if !self.current_segments.is_empty() { @@ -48,6 +48,14 @@ impl PathBuilder { self.current_segments.clear(); } + true + } + + pub fn draw_glyph(&mut self, glyph: &OutlineGlyph<'_>, size: f32, normalized_coords: &[NormalizedCoord], style_skew: Option, final_transform: DAffine2, per_glyph_instances: bool) { + if !self.outline_glyph(glyph, size, normalized_coords) { + return; + } + let transform = if self.is_text_on_path { final_transform } else { @@ -72,6 +80,30 @@ impl PathBuilder { } } + pub fn draw_glyph_with_mapping(&mut self, glyph: &OutlineGlyph<'_>, size: f32, normalized_coords: &[NormalizedCoord], style_skew: Option, mapping_function: impl Fn(DVec2) -> DVec2) { + if !self.outline_glyph(glyph, size, normalized_coords) { + return; + } + + let subpaths = std::mem::take(&mut self.glyph_subpaths) + .into_iter() + .map(|mut subpath| { + for manipulator_group in subpath.manipulator_groups_mut() { + let transform_point = |point| { + 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); + } + subpath + }) + .collect::>(); + + self.vector_table.push(TableRow::new_from_element(Vector::from_subpaths(subpaths, false))); + } + pub fn render_glyph_run(&mut self, glyph_run: &parley::GlyphRun<'_, ()>, tilt: f64, per_glyph_instances: bool) { let run = glyph_run.run(); let mut run_x = glyph_run.offset(); diff --git a/node-graph/nodes/text/src/text_on_path.rs b/node-graph/nodes/text/src/text_on_path.rs index 0659f3499e..6a0afae47f 100644 --- a/node-graph/nodes/text/src/text_on_path.rs +++ b/node-graph/nodes/text/src/text_on_path.rs @@ -227,6 +227,20 @@ fn text_path_spacing_adjustment(spacing: TextPathSpacing, lut: &ArcLengthLut, mi } } +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, @@ -246,11 +260,6 @@ pub fn place_text_on_path( rtl: bool, font_cache: &crate::FontCache, ) -> Table> { - // TODO: Support method="stretch" (warp glyph outlines along path perpendiculars) - if method == TextPathMethod::Stretch { - log::warn!("textPath method='stretch' is not yet implemented; falling back to 'align'"); - } - let Some(original_bezpath) = path_table.iter().next().and_then(|row| row.element.stroke_bezpath_iter().find(|p| p.segments().next().is_some())) else { return Table::new(); }; @@ -325,15 +334,23 @@ pub fn place_text_on_path( glyph_index += 1; if !is_glyph_hidden(adjusted_mid, abs_offset, lut.total_length, lut.is_closed, text_anchor, rtl) { - let effective_mid = if lut.is_closed { adjusted_mid.rem_euclid(lut.total_length) } else { adjusted_mid }; - let (point, angle) = if lut.is_closed { lut.at_or_zero(effective_mid) } else { at_with_extension(&lut, effective_mid) }; - if let Some(glyph_outline) = outlines.get(skrifa::GlyphId::from(glyph.id)) { - 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(&glyph_outline, font_size, &normalized_coords, style_skew, final_transform, true); + 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(&glyph_outline, font_size, &normalized_coords, style_skew, final_transform, true); + } + 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) + }); + } + } } } }); From fc6c9e250260e2257736ec959aacf0a21852bcec Mon Sep 17 00:00:00 2001 From: Kulratan Date: Tue, 5 May 2026 17:15:18 +0000 Subject: [PATCH 5/5] Fix export --- .../graph_operation_message_handler.rs | 31 ++++++++---- .../document/graph_operation/utility_types.rs | 11 +++-- .../libraries/rendering/src/renderer.rs | 9 +++- .../vector-types/src/vector/vector_types.rs | 14 +++++- node-graph/nodes/text/src/path_builder.rs | 29 +++++++----- node-graph/nodes/text/src/text_on_path.rs | 47 ++++++++++++++----- 6 files changed, 102 insertions(+), 39 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 8a89963fee..dd11fa2023 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 @@ -619,11 +619,14 @@ fn escape_xml_attr(value: &str) -> String { #[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> { @@ -638,11 +641,14 @@ fn pre_parse_textpath_attrs(svg: &str) -> std::collections::HashMap>, ) { log::info!("Importing usvg text node with {} chunks", text.chunks().len()); - for (i, chunk) in text.chunks().iter().enumerate() { - let current_layer = if i == 0 { - layer - } else { + 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, parent, insert_index, &[]); + 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); @@ -834,7 +841,12 @@ fn import_usvg_text( 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 = text_path.start_offset() as f64; + + 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(), @@ -843,12 +855,15 @@ fn import_usvg_text( 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, ); 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 239cab8fb2..074eedede2 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -298,12 +298,15 @@ impl<'a> ModifyInputsContext<'a> { character_spacing: f64, path_subpaths: Vec>, start_offset: f64, + start_offset_percent: bool, text_anchor: TextAnchor, side: graphene_std::text::TextPathSide, method: graphene_std::text::TextPathMethod, spacing: graphene_std::text::TextPathSpacing, text_length: Option, length_adjust: graphene_std::text::LengthAdjust, + path_length: Option, + rtl: bool, transform: DAffine2, layer: LayerNodeIdentifier, ) { @@ -318,7 +321,7 @@ impl<'a> ModifyInputsContext<'a> { 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(false), 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)), @@ -326,9 +329,9 @@ impl<'a> ModifyInputsContext<'a> { 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(false), false)), - Some(NodeInput::value(TaggedValue::F64(0.0), false)), - Some(NodeInput::value(TaggedValue::Bool(false), 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(); diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index e466b5b8f0..c331c1e3d2 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -794,9 +794,14 @@ impl Render for Table { impl Render for Table { fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { + let mut text_on_path_exported = false; for row in self.iter() { if render_params.for_export { if let Some(ref meta) = row.element.text_on_path_metadata { + if text_on_path_exported { + continue; + } + 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(); @@ -813,9 +818,11 @@ impl Render for Table { 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}"##)); + render.leaf_node(format!(r##"{text}"##)); continue; } } 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 7ba6be9a1d..1d51114b7c 100644 --- a/node-graph/libraries/vector-types/src/vector/vector_types.rs +++ b/node-graph/libraries/vector-types/src/vector/vector_types.rs @@ -40,6 +40,8 @@ pub struct TextOnPathMetadata { 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. @@ -98,8 +100,18 @@ impl std::hash::Hash for Vector { metadata.text.hash(state); metadata.font_family.hash(state); metadata.font_style.hash(state); - (metadata.font_size as u64).hash(state); + metadata.font_size.to_bits().hash(state); metadata.path_d.hash(state); + metadata.start_offset.to_bits().hash(state); + metadata.start_offset_percent.hash(state); + metadata.text_anchor.hash(state); + metadata.side.hash(state); + metadata.method.hash(state); + metadata.spacing.hash(state); + metadata.text_length.map(|tl| tl.to_bits()).hash(state); + metadata.length_adjust.hash(state); + metadata.path_length.map(|pl| pl.to_bits()).hash(state); + metadata.rtl.hash(state); } } } diff --git a/node-graph/nodes/text/src/path_builder.rs b/node-graph/nodes/text/src/path_builder.rs index bfd3021f4f..b450e1e7a1 100644 --- a/node-graph/nodes/text/src/path_builder.rs +++ b/node-graph/nodes/text/src/path_builder.rs @@ -14,6 +14,7 @@ pub struct PathBuilder { current_point: Point, is_text_on_path: bool, scale: f64, + glyph_index: u64, } impl PathBuilder { @@ -25,6 +26,7 @@ impl PathBuilder { current_point: Point::ZERO, is_text_on_path, scale, + glyph_index: 0, } } @@ -63,21 +65,17 @@ impl PathBuilder { }; let transform = if let Some(skew) = style_skew { transform * skew } else { transform }; - let subpaths = std::mem::take(&mut self.glyph_subpaths); + let mut vector = Vector::from_subpaths(self.glyph_subpaths.clone(), false); + vector.transform(transform); if per_glyph_instances { - let mut vector = Vector::from_subpaths(subpaths, false); - vector.transform(transform); self.vector_table.push(TableRow::new_from_element(vector)); + } else if self.vector_table.is_empty() { + self.vector_table = Table::new_from_element(vector); } else { - let mut vector = Vector::from_subpaths(subpaths, false); - vector.transform(transform); - if self.vector_table.is_empty() { - self.vector_table = Table::new_from_element(vector); - } else { - let current_vector = self.vector_table.iter_mut().next().unwrap(); - current_vector.element.concat(&vector, DAffine2::IDENTITY, 0); - } + let current_vector = self.vector_table.iter_mut().next().unwrap(); + current_vector.element.concat(&vector, DAffine2::IDENTITY, self.glyph_index); } + self.glyph_index += 1; } pub fn draw_glyph_with_mapping(&mut self, glyph: &OutlineGlyph<'_>, size: f32, normalized_coords: &[NormalizedCoord], style_skew: Option, mapping_function: impl Fn(DVec2) -> DVec2) { @@ -110,7 +108,14 @@ impl PathBuilder { let run_y = glyph_run.baseline(); let synthesis = run.synthesis(); - let style_skew = synthesis.skew().map(|angle| DAffine2::from_cols_array(&[1., 0., -(angle as f64).to_radians().tan(), 1., 0., 0.])); + let style_skew = synthesis.skew().map(|angle| { + let skew = DAffine2::from_cols_array(&[1., 0., -(angle as f64).to_radians().tan(), 1., 0., 0.]); + if per_glyph_instances || self.is_text_on_path { + skew + } else { + DAffine2::from_translation(DVec2::new(0., run_y as f64)) * skew * DAffine2::from_translation(DVec2::new(0., -run_y as f64)) + } + }); let tilt_skew = (tilt != 0.).then(|| DAffine2::from_cols_array(&[1., 0., -tilt.to_radians().tan(), 1., 0., 0.])); let font = run.font(); diff --git a/node-graph/nodes/text/src/text_on_path.rs b/node-graph/nodes/text/src/text_on_path.rs index 6a0afae47f..b29038e563 100644 --- a/node-graph/nodes/text/src/text_on_path.rs +++ b/node-graph/nodes/text/src/text_on_path.rs @@ -204,11 +204,19 @@ fn is_glyph_hidden(mid: f64, _start_offset: f64, total_length: f64, is_closed: b mid < -1e-3 || mid > total_length + 1e-3 } -fn resolve_startpoint(abs_offset: f64, total_advance: f64, text_anchor: TextAnchor) -> f64 { - match text_anchor { - TextAnchor::Start => abs_offset, - TextAnchor::Middle => abs_offset - total_advance / 2.0, - TextAnchor::End => abs_offset - total_advance, +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, + } } } @@ -285,10 +293,18 @@ pub fn place_text_on_path( log::info!("Placing text on path: {} (length: {})", text, lut.total_length); - let mut abs_offset = if start_offset_percent { start_offset * lut.total_length } else { start_offset }; - if let Some(author_length) = path_length.filter(|&l| l > 1e-9) { - abs_offset *= lut.total_length / author_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(true, layout.scale() as f64); @@ -307,7 +323,7 @@ pub fn place_text_on_path( }; 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); + 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; @@ -326,9 +342,12 @@ 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_origin = line_start + (run_x as f64 - glyph_run.offset() as f64 + glyph.x as f64) * advance_scale + cumulative_offset; - let mid = glyph_origin + scaled_advance / 2.0; - let adjusted_mid = mid + text_path_spacing_adjustment(spacing, &lut, mid, scaled_advance); + + 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; @@ -396,6 +415,8 @@ pub fn place_text_on_path( LengthAdjust::SpacingAndGlyphs => "spacingAndGlyphs", } .to_string(), + path_length, + rtl, }); for row in result.iter_mut() { row.element.text_on_path_metadata = Some(Arc::clone(&metadata));