Skip to content

Commit c921696

Browse files
committed
Add Text-on-path support
1 parent 1d03372 commit c921696

13 files changed

Lines changed: 556 additions & 176 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ kurbo = { version = "0.13", features = ["serde"] }
150150
vello = "0.7"
151151
vello_encoding = "0.7"
152152
resvg = "0.47"
153-
usvg = "0.47"
153+
usvg = { version = "0.47", features = ["text", "system-fonts", "memmap-fonts"] }
154154
parley = "0.6"
155155
skrifa = "0.40"
156156
polycool = "0.4"

editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs

Lines changed: 64 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,10 @@ use graph_craft::document::value::TaggedValue;
1212
use graph_craft::document::{NodeId, NodeInput};
1313
use graphene_std::Color;
1414
use graphene_std::renderer::Quad;
15-
use graphene_std::renderer::convert_usvg_path::convert_usvg_path;
15+
use graphene_std::renderer::convert_usvg_path::{convert_tiny_skia_path, convert_usvg_path};
1616
use graphene_std::table::Table;
17-
use graphene_std::text::{Font, TypesettingConfig};
17+
use graphene_std::text::{Font, TextAnchor, TypesettingConfig};
1818
use graphene_std::vector::style::{Fill, Gradient, GradientStop, GradientStops, GradientType, PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin};
19-
2019
#[derive(ExtractField)]
2120
pub struct GraphOperationMessageContext<'a> {
2221
pub network_interface: &'a mut NodeNetworkInterface,
@@ -394,7 +393,14 @@ impl MessageHandler<GraphOperationMessage, GraphOperationMessageContext<'_>> for
394393
insert_index,
395394
center,
396395
} => {
397-
let tree = match usvg::Tree::from_str(&svg, &usvg::Options::default()) {
396+
let mut options = usvg::Options::default();
397+
options.fontdb_mut().load_font_data(include_bytes!("../../../../font.ttf").to_vec());
398+
options.font_family = "Source Sans Pro".to_string();
399+
400+
let svg = svg.replace("font-family=\"sans-serif\"", "font-family=\"Source Sans Pro\"");
401+
let svg = svg.replace("font-family='sans-serif'", "font-family='Source Sans Pro'");
402+
403+
let tree = match usvg::Tree::from_str(&svg, &options) {
398404
Ok(t) => t,
399405
Err(e) => {
400406
responses.add(DialogMessage::DisplayDialogError {
@@ -546,6 +552,7 @@ fn import_usvg_node(
546552
insert_index: usize,
547553
graphite_gradient_stops: &HashMap<String, GradientStops>,
548554
) {
555+
log::error!("DIAGNOSTIC: Visiting node root: {:?}", node);
549556
let layer = modify_inputs.create_layer(id);
550557

551558
modify_inputs.network_interface.move_layer_to_stack(layer, parent, insert_index, &[]);
@@ -590,9 +597,7 @@ fn import_usvg_node(
590597
warn!("Skip image");
591598
}
592599
usvg::Node::Text(text) => {
593-
let font = Font::new(graphene_std::consts::DEFAULT_FONT_FAMILY.to_string(), graphene_std::consts::DEFAULT_FONT_STYLE.to_string());
594-
modify_inputs.insert_text(text.chunks().iter().map(|chunk| chunk.text()).collect(), font, TypesettingConfig::default(), layer);
595-
modify_inputs.fill_set(Fill::Solid(Color::BLACK));
600+
import_usvg_text(modify_inputs, text, node.abs_transform(), layer);
596601
}
597602
}
598603
}
@@ -633,24 +638,21 @@ fn import_usvg_node_inner(
633638
group_extents_map.insert(layer, child_extents);
634639
total_extent
635640
}
636-
usvg::Node::Path(path) => {
637-
import_usvg_path(modify_inputs, node, path, layer, graphite_gradient_stops);
638-
0
639-
}
640641
usvg::Node::Image(_image) => {
641642
warn!("Skip image");
642643
0
643644
}
644645
usvg::Node::Text(text) => {
645-
let font = Font::new(graphene_std::consts::DEFAULT_FONT_FAMILY.to_string(), graphene_std::consts::DEFAULT_FONT_STYLE.to_string());
646-
modify_inputs.insert_text(text.chunks().iter().map(|chunk| chunk.text()).collect(), font, TypesettingConfig::default(), layer);
647-
modify_inputs.fill_set(Fill::Solid(Color::BLACK));
646+
import_usvg_text(modify_inputs, text, node.abs_transform(), layer);
647+
0
648+
}
649+
usvg::Node::Path(path) => {
650+
import_usvg_path(modify_inputs, node, path, layer, graphite_gradient_stops);
648651
0
649652
}
650653
}
651654
}
652655

653-
/// Helper to apply path data (vector geometry, fill, stroke, transform) to a layer.
654656
fn import_usvg_path(modify_inputs: &mut ModifyInputsContext, node: &usvg::Node, path: &usvg::Path, layer: LayerNodeIdentifier, graphite_gradient_stops: &HashMap<String, GradientStops>) {
655657
let subpaths = convert_usvg_path(path);
656658
let bounds = subpaths.iter().filter_map(|subpath| subpath.bounding_box()).reduce(Quad::combine_bounds).unwrap_or_default();
@@ -674,6 +676,53 @@ fn import_usvg_path(modify_inputs: &mut ModifyInputsContext, node: &usvg::Node,
674676
}
675677
}
676678

679+
fn import_usvg_text(modify_inputs: &mut ModifyInputsContext, text: &usvg::Text, transform: usvg::Transform, layer: LayerNodeIdentifier) {
680+
use graphene_std::text::TextAnchor;
681+
let font_family = text
682+
.chunks()
683+
.first()
684+
.and_then(|chunk| chunk.spans().first())
685+
.and_then(|span| span.font().families().first().map(|f| f.to_string()))
686+
.unwrap_or_else(|| graphene_std::consts::DEFAULT_FONT_FAMILY.to_string());
687+
let font_style = graphene_std::consts::DEFAULT_FONT_STYLE.to_string();
688+
let font = Font::new(font_family, font_style);
689+
690+
let full_text: String = text.chunks().iter().map(|chunk| chunk.text()).collect();
691+
692+
// Check if any chunk uses text-on-path (TextFlow::Path)
693+
let text_path_chunk = text.chunks().iter().find(|chunk| matches!(chunk.text_flow(), usvg::TextFlow::Path(_)));
694+
695+
if let Some(chunk) = text_path_chunk {
696+
if let usvg::TextFlow::Path(text_path) = chunk.text_flow() {
697+
let path_subpaths = convert_tiny_skia_path(text_path.path());
698+
let start_offset = text_path.start_offset() as f64;
699+
let anchor = match chunk.anchor() {
700+
usvg::TextAnchor::Start => TextAnchor::Start,
701+
usvg::TextAnchor::Middle => TextAnchor::Middle,
702+
usvg::TextAnchor::End => TextAnchor::End,
703+
};
704+
let font_size = chunk.spans().first().map(|s| s.font_size().get()).unwrap_or(24.0) as f64;
705+
let letter_spacing = chunk.spans().first().map(|s| s.letter_spacing()).unwrap_or(0.0) as f64;
706+
707+
let affine = DAffine2::from_cols_array(&[
708+
transform.sx as f64,
709+
transform.ky as f64,
710+
transform.kx as f64,
711+
transform.sy as f64,
712+
transform.tx as f64,
713+
transform.ty as f64,
714+
]);
715+
modify_inputs.insert_text_on_path(full_text, font, font_size, letter_spacing, path_subpaths, start_offset, anchor, affine, layer);
716+
modify_inputs.fill_set(Fill::Solid(Color::BLACK));
717+
return;
718+
}
719+
}
720+
721+
// Fallback: regular text
722+
modify_inputs.insert_text(full_text, font, TypesettingConfig::default(), layer);
723+
modify_inputs.fill_set(Fill::Solid(Color::BLACK));
724+
}
725+
677726
/// Set correct positions for all imported layers in a single top-down O(n) pass.
678727
///
679728
/// For each group's child stack:

editor/src/messages/portfolio/document/graph_operation/utility_types.rs

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ use crate::messages::portfolio::document::node_graph::document_node_definitions:
33
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
44
use crate::messages::portfolio::document::utility_types::network_interface::{self, InputConnector, NodeNetworkInterface, OutputConnector};
55
use crate::messages::prelude::*;
6-
use glam::{DAffine2, IVec2};
6+
use glam::{DAffine2, DVec2, IVec2};
7+
use graphene_std::transform::Transform as _;
78
use graph_craft::document::value::TaggedValue;
89
use graph_craft::document::{NodeId, NodeInput};
910
use graph_craft::{ProtoNodeIdentifier, concrete};
@@ -13,7 +14,7 @@ use graphene_std::raster::BlendMode;
1314
use graphene_std::raster_types::{CPU, Raster};
1415
use graphene_std::subpath::Subpath;
1516
use graphene_std::table::Table;
16-
use graphene_std::text::{Font, TypesettingConfig};
17+
use graphene_std::text::{Font, TextAnchor, TypesettingConfig};
1718
use graphene_std::vector::Vector;
1819
use graphene_std::vector::style::{Fill, Stroke};
1920
use graphene_std::vector::{PointId, VectorModificationType};
@@ -289,6 +290,64 @@ impl<'a> ModifyInputsContext<'a> {
289290
self.network_interface.move_node_to_chain_start(&fill_id, layer, &[], self.import);
290291
}
291292

293+
294+
pub fn insert_text_on_path(&mut self, text: String, font: Font, font_size: f64, character_spacing: f64, path_subpaths: Vec<Subpath<PointId>>, start_offset: f64, text_anchor: TextAnchor, transform: DAffine2, layer: LayerNodeIdentifier) {
295+
use graphene_std::text::TextPathSide;
296+
297+
// Create the path vector object
298+
let path_vector = Table::new_from_element(Vector::from_subpaths(path_subpaths, true));
299+
300+
// Create the Text On Path node directly using the vector as a value input
301+
let text_on_path_node = resolve_proto_node_type(graphene_std::text::text_on_path::IDENTIFIER)
302+
.expect("Text On Path node does not exist")
303+
.node_template_input_override([
304+
Some(NodeInput::scope("editor-api")),
305+
Some(NodeInput::value(TaggedValue::String(text), false)),
306+
Some(NodeInput::value(TaggedValue::Vector(path_vector), false)), // path input passed as vector value directly
307+
Some(NodeInput::value(TaggedValue::Font(font), false)),
308+
Some(NodeInput::value(TaggedValue::F64(font_size), false)),
309+
Some(NodeInput::value(TaggedValue::F64(character_spacing), false)),
310+
Some(NodeInput::value(TaggedValue::F64(start_offset), false)), // start_offset
311+
Some(NodeInput::value(TaggedValue::Bool(false), false)), // start_offset_percent
312+
Some(NodeInput::value(TaggedValue::TextPathSide(TextPathSide::Left), false)),
313+
Some(NodeInput::value(TaggedValue::TextAnchor(text_anchor), false)),
314+
]);
315+
316+
let text_on_path_id = NodeId::new();
317+
self.network_interface.insert_node(text_on_path_id, text_on_path_node, &[]);
318+
self.network_interface.move_node_to_chain_start(&text_on_path_id, layer, &[], self.import);
319+
320+
let (rotation, scale, skew): (f64, DVec2, f64) = transform.decompose_rotation_scale_skew();
321+
let translation = transform.translation;
322+
let rotation = rotation.to_degrees();
323+
let skew = DVec2::new(skew.atan().to_degrees(), 0.);
324+
325+
let transform_node = resolve_network_node_type("Transform").expect("Transform node does not exist").node_template_input_override([
326+
None,
327+
Some(NodeInput::value(TaggedValue::DVec2(translation), false)),
328+
Some(NodeInput::value(TaggedValue::F64(rotation), false)),
329+
Some(NodeInput::value(TaggedValue::DVec2(scale), false)),
330+
Some(NodeInput::value(TaggedValue::DVec2(skew), false)),
331+
]);
332+
let transform_id = NodeId::new();
333+
self.network_interface.insert_node(transform_id, transform_node, &[]);
334+
self.network_interface.move_node_to_chain_start(&transform_id, layer, &[], self.import);
335+
336+
let stroke = resolve_proto_node_type(graphene_std::vector_nodes::stroke::IDENTIFIER)
337+
.expect("Stroke node does not exist")
338+
.default_node_template();
339+
let stroke_id = NodeId::new();
340+
self.network_interface.insert_node(stroke_id, stroke, &[]);
341+
self.network_interface.move_node_to_chain_start(&stroke_id, layer, &[], self.import);
342+
343+
let fill = resolve_proto_node_type(graphene_std::vector_nodes::fill::IDENTIFIER)
344+
.expect("Fill node does not exist")
345+
.default_node_template();
346+
let fill_id = NodeId::new();
347+
self.network_interface.insert_node(fill_id, fill, &[]);
348+
self.network_interface.move_node_to_chain_start(&fill_id, layer, &[], self.import);
349+
}
350+
292351
pub fn insert_image_data(&mut self, image_frame: Table<Raster<CPU>>, layer: LayerNodeIdentifier) {
293352
let transform = resolve_network_node_type("Transform").expect("Transform node does not exist").default_node_template();
294353
let image = resolve_proto_node_type(graphene_std::raster_nodes::std_nodes::image_value::IDENTIFIER)

node-graph/graph-craft/src/document/value.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,8 @@ tagged_value! {
273273
CentroidType(vector::misc::CentroidType),
274274
BooleanOperation(vector::misc::BooleanOperation),
275275
TextAlign(text_nodes::TextAlign),
276+
TextPathSide(text_nodes::text_on_path::TextPathSide),
277+
TextAnchor(text_nodes::text_on_path::TextAnchor),
276278
ScaleType(core_types::transform::ScaleType),
277279
}
278280

node-graph/libraries/rendering/src/convert_usvg_path.rs

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,22 @@ use vector_types::subpath::{ManipulatorGroup, Subpath};
33
use vector_types::vector::PointId;
44

55
pub fn convert_usvg_path(path: &usvg::Path) -> Vec<Subpath<PointId>> {
6+
convert_tiny_skia_path(path.data())
7+
}
8+
9+
pub fn convert_tiny_skia_path(path_data: &usvg::tiny_skia_path::Path) -> Vec<Subpath<PointId>> {
610
let mut subpaths = Vec::new();
711
let mut manipulators_list = Vec::new();
812

9-
let mut points = path.data().points().iter();
13+
let mut points = path_data.points().iter();
1014
let to_vec = |p: &usvg::tiny_skia_path::Point| DVec2::new(p.x as f64, p.y as f64);
1115

12-
for verb in path.data().verbs() {
16+
for verb in path_data.verbs() {
1317
match verb {
1418
usvg::tiny_skia_path::PathVerb::Move => {
15-
subpaths.push(Subpath::new(std::mem::take(&mut manipulators_list), false));
19+
if !manipulators_list.is_empty() {
20+
subpaths.push(Subpath::new(std::mem::take(&mut manipulators_list), false));
21+
}
1622
let Some(start) = points.next().map(to_vec) else { continue };
1723
manipulators_list.push(ManipulatorGroup::new(start, Some(start), Some(start)));
1824
}
@@ -38,10 +44,14 @@ pub fn convert_usvg_path(path: &usvg::Path) -> Vec<Subpath<PointId>> {
3844
manipulators_list.push(ManipulatorGroup::new(end, Some(second_handle), Some(end)));
3945
}
4046
usvg::tiny_skia_path::PathVerb::Close => {
41-
subpaths.push(Subpath::new(std::mem::take(&mut manipulators_list), true));
47+
if !manipulators_list.is_empty() {
48+
subpaths.push(Subpath::new(std::mem::take(&mut manipulators_list), true));
49+
}
4250
}
4351
}
4452
}
45-
subpaths.push(Subpath::new(manipulators_list, false));
53+
if !manipulators_list.is_empty() {
54+
subpaths.push(Subpath::new(manipulators_list, false));
55+
}
4656
subpaths
4757
}

node-graph/nodes/gstd/src/text.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use core_types::{Ctx, table::Table};
22
use graph_craft::wasm_application_io::WasmEditorApi;
33
use graphic_types::Vector;
44
pub use text_nodes::*;
5+
pub use text_nodes::text_on_path::{TextAnchor, TextPathSide};
56

67
/// Draws a text string as vector geometry with a choice of font and styling.
78
#[node_macro::node(category("Text"))]
@@ -74,3 +75,49 @@ fn text<'i: 'n>(
7475

7576
to_path(&text, &font, &editor_resources.font_cache, typesetting, separate_glyph_elements)
7677
}
78+
79+
/// Flows text glyphs along a vector path following the SVG 2 text-on-path layout rules (§11.8).
80+
#[node_macro::node(category("Text"))]
81+
fn text_on_path<'i: 'n>(
82+
_: impl Ctx,
83+
#[scope("editor-api")]
84+
editor_resources: &'i WasmEditorApi,
85+
/// The text content to flow along the path.
86+
#[default("Lorem ipsum")]
87+
text: String,
88+
/// The vector path that glyphs follow.
89+
path: Table<Vector>,
90+
/// The typeface used to draw the text.
91+
font: Font,
92+
/// The font size in pixels.
93+
#[unit(" px")]
94+
#[default(24.)]
95+
#[hard_min(1.)]
96+
size: f64,
97+
/// Additional spacing, in pixels, added between each character.
98+
#[unit(" px")]
99+
#[step(0.1)]
100+
character_spacing: f64,
101+
/// Arc-length offset from the path start to the first glyph.
102+
#[unit(" px")]
103+
start_offset: f64,
104+
/// If true, start_offset is treated as a 0–1 fraction of total path length.
105+
start_offset_percent: bool,
106+
/// Which side of the path direction to place text.
107+
side: text_nodes::text_on_path::TextPathSide,
108+
/// Text anchor point — affects where along the path the text is anchored.
109+
text_anchor: text_nodes::text_on_path::TextAnchor,
110+
) -> Table<Vector> {
111+
text_nodes::text_on_path::place_text_on_path(
112+
&text,
113+
&path,
114+
&font,
115+
size,
116+
character_spacing,
117+
start_offset,
118+
start_offset_percent,
119+
side,
120+
text_anchor,
121+
&editor_resources.font_cache,
122+
)
123+
}

node-graph/nodes/text/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ dyn-any = { workspace = true }
2121
glam = { workspace = true }
2222
parley = { workspace = true }
2323
skrifa = { workspace = true }
24+
kurbo = { workspace = true }
2425
log = { workspace = true }
2526

2627
# Optional workspace dependencies

0 commit comments

Comments
 (0)