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/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);