Skip to content

Commit 0797e9e

Browse files
committed
Implement W3C SVG 2 compliant text-on-path support
1 parent 1d03372 commit 0797e9e

18 files changed

Lines changed: 833 additions & 157 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: 112 additions & 16 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;
1717
use graphene_std::text::{Font, 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!("../overlays/source-sans-pro-regular.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 {
@@ -424,6 +430,9 @@ impl MessageHandler<GraphOperationMessage, GraphOperationMessageContext<'_>> for
424430

425431
let graphite_gradient_stops = extract_graphite_gradient_stops(&svg);
426432

433+
// Pre-parse the raw SVG XML for <textPath> attributes that usvg doesn't expose
434+
let mut textpath_attrs = pre_parse_textpath_attrs(&svg);
435+
427436
// Pass identity so each leaf layer receives only its SVG-native transform from `abs_transform`.
428437
// The placement offset is then applied once to the root group layer below.
429438
import_usvg_node(
@@ -433,6 +442,7 @@ impl MessageHandler<GraphOperationMessage, GraphOperationMessageContext<'_>> for
433442
parent,
434443
insert_index,
435444
&graphite_gradient_stops,
445+
&mut textpath_attrs,
436446
);
437447

438448
// 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<Color> {
532542
Some(Color::from_rgbaf32_unchecked(r, g, b, opacity))
533543
}
534544

545+
#[derive(Debug, Default, Clone)]
546+
struct TextPathAttrs {
547+
pub method: Option<String>,
548+
pub spacing: Option<String>,
549+
pub side: Option<String>,
550+
pub text_length: Option<f64>,
551+
pub length_adjust: Option<String>,
552+
}
553+
554+
fn pre_parse_textpath_attrs(svg: &str) -> std::collections::HashMap<String, Vec<TextPathAttrs>> {
555+
let mut map = std::collections::HashMap::<String, Vec<TextPathAttrs>>::new();
556+
let doc = match usvg::roxmltree::Document::parse(svg) {
557+
Ok(doc) => doc,
558+
Err(_) => return map,
559+
};
560+
for node in doc.descendants() {
561+
if node.tag_name().name() == "textPath" {
562+
let id = node.attribute("id").unwrap_or("").to_string();
563+
map.entry(id).or_default().push(TextPathAttrs {
564+
method: node.attribute("method").map(str::to_string),
565+
spacing: node.attribute("spacing").map(str::to_string),
566+
side: node.attribute("side").map(str::to_string),
567+
text_length: node.attribute("textLength").and_then(|v| v.parse().ok()),
568+
length_adjust: node.attribute("lengthAdjust").map(str::to_string),
569+
});
570+
}
571+
}
572+
map
573+
}
574+
535575
/// Import a usvg node as the root of an SVG import operation.
536576
///
537577
/// The root layer uses the full `move_layer_to_stack` (with push/collision logic) to correctly
@@ -545,6 +585,7 @@ fn import_usvg_node(
545585
parent: LayerNodeIdentifier,
546586
insert_index: usize,
547587
graphite_gradient_stops: &HashMap<String, GradientStops>,
588+
textpath_attrs: &mut HashMap<String, Vec<TextPathAttrs>>,
548589
) {
549590
let layer = modify_inputs.create_layer(id);
550591

@@ -565,7 +606,7 @@ fn import_usvg_node(
565606
modify_inputs.import = true;
566607

567608
for child in group.children() {
568-
let extent = import_usvg_node_inner(modify_inputs, child, NodeId::new(), layer, 0, graphite_gradient_stops, &mut group_extents_map);
609+
let extent = import_usvg_node_inner(modify_inputs, child, NodeId::new(), layer, 0, graphite_gradient_stops, &mut group_extents_map, textpath_attrs);
569610
child_extents_svg_order.push(extent);
570611
}
571612

@@ -590,9 +631,7 @@ fn import_usvg_node(
590631
warn!("Skip image");
591632
}
592633
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));
634+
import_usvg_text(modify_inputs, text, node.abs_transform(), layer, parent, insert_index, textpath_attrs);
596635
}
597636
}
598637
}
@@ -610,6 +649,7 @@ fn import_usvg_node_inner(
610649
insert_index: usize,
611650
graphite_gradient_stops: &HashMap<String, GradientStops>,
612651
group_extents_map: &mut HashMap<LayerNodeIdentifier, Vec<u32>>,
652+
textpath_attrs: &mut HashMap<String, Vec<TextPathAttrs>>,
613653
) -> u32 {
614654
let layer = modify_inputs.create_layer(id);
615655
modify_inputs.network_interface.move_layer_to_stack_for_import(layer, parent, insert_index, &[]);
@@ -619,7 +659,7 @@ fn import_usvg_node_inner(
619659
usvg::Node::Group(group) => {
620660
let mut child_extents: Vec<u32> = Vec::new();
621661
for child in group.children() {
622-
let extent = import_usvg_node_inner(modify_inputs, child, NodeId::new(), layer, 0, graphite_gradient_stops, group_extents_map);
662+
let extent = import_usvg_node_inner(modify_inputs, child, NodeId::new(), layer, 0, graphite_gradient_stops, group_extents_map, textpath_attrs);
623663
child_extents.push(extent);
624664
}
625665
modify_inputs.layer_node = Some(layer);
@@ -633,24 +673,21 @@ fn import_usvg_node_inner(
633673
group_extents_map.insert(layer, child_extents);
634674
total_extent
635675
}
636-
usvg::Node::Path(path) => {
637-
import_usvg_path(modify_inputs, node, path, layer, graphite_gradient_stops);
638-
0
639-
}
640676
usvg::Node::Image(_image) => {
641677
warn!("Skip image");
642678
0
643679
}
644680
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));
681+
import_usvg_text(modify_inputs, text, node.abs_transform(), layer, parent, insert_index, textpath_attrs);
682+
0
683+
}
684+
usvg::Node::Path(path) => {
685+
import_usvg_path(modify_inputs, node, path, layer, graphite_gradient_stops);
648686
0
649687
}
650688
}
651689
}
652690

653-
/// Helper to apply path data (vector geometry, fill, stroke, transform) to a layer.
654691
fn import_usvg_path(modify_inputs: &mut ModifyInputsContext, node: &usvg::Node, path: &usvg::Path, layer: LayerNodeIdentifier, graphite_gradient_stops: &HashMap<String, GradientStops>) {
655692
let subpaths = convert_usvg_path(path);
656693
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,
674711
}
675712
}
676713

714+
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<String, Vec<TextPathAttrs>>) {
715+
use graphene_std::text::{LengthAdjust, TextAnchor, TextPathMethod, TextPathSide, TextPathSpacing};
716+
717+
for (i, chunk) in text.chunks().iter().enumerate() {
718+
let current_layer = if i == 0 {
719+
layer
720+
} else {
721+
let new_id = NodeId::new();
722+
let new_layer = modify_inputs.create_layer(new_id);
723+
modify_inputs.network_interface.move_layer_to_stack_for_import(new_layer, parent, insert_index, &[]);
724+
new_layer
725+
};
726+
modify_inputs.layer_node = Some(current_layer);
727+
728+
let font_family = chunk
729+
.spans()
730+
.first()
731+
.and_then(|span| span.font().families().first().map(|f| f.to_string()))
732+
.unwrap_or_else(|| graphene_std::consts::DEFAULT_FONT_FAMILY.to_string());
733+
let font_style = graphene_std::consts::DEFAULT_FONT_STYLE.to_string();
734+
let font = Font::new(font_family, font_style);
735+
736+
let font_size = chunk.spans().first().map(|s| s.font_size().get()).unwrap_or(24.0) as f64;
737+
let letter_spacing = chunk.spans().first().map(|s| s.letter_spacing()).unwrap_or(0.0) as f64;
738+
739+
if let usvg::TextFlow::Path(text_path) = chunk.text_flow() {
740+
let tp_id = text_path.id();
741+
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();
742+
let path_subpaths = convert_tiny_skia_path(text_path.path());
743+
let start_offset = text_path.start_offset() as f64;
744+
let anchor = match chunk.anchor() {
745+
usvg::TextAnchor::Start => TextAnchor::Start,
746+
usvg::TextAnchor::Middle => TextAnchor::Middle,
747+
usvg::TextAnchor::End => TextAnchor::End,
748+
};
749+
750+
let affine = DAffine2::from_cols_array(&[
751+
transform.sx as f64,
752+
transform.ky as f64,
753+
transform.kx as f64,
754+
transform.sy as f64,
755+
transform.tx as f64,
756+
transform.ty as f64,
757+
]);
758+
let method = if tp_attrs.method.as_deref() == Some("stretch") { TextPathMethod::Stretch } else { TextPathMethod::Align };
759+
let spacing = if tp_attrs.spacing.as_deref() == Some("auto") { TextPathSpacing::Auto } else { TextPathSpacing::Exact };
760+
let side = if tp_attrs.side.as_deref() == Some("right") { TextPathSide::Right } else { TextPathSide::Left };
761+
let length_adjust = if tp_attrs.length_adjust.as_deref() == Some("spacingAndGlyphs") { LengthAdjust::SpacingAndGlyphs } else { LengthAdjust::Spacing };
762+
763+
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);
764+
modify_inputs.fill_set(Fill::Solid(Color::BLACK));
765+
} else {
766+
// Regular text fallback
767+
modify_inputs.insert_text(chunk.text().to_string(), font, TypesettingConfig { font_size, ..Default::default() }, current_layer);
768+
modify_inputs.fill_set(Fill::Solid(Color::BLACK));
769+
}
770+
}
771+
}
772+
677773
/// Set correct positions for all imported layers in a single top-down O(n) pass.
678774
///
679775
/// For each group's child stack:

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

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ 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};
77
use graph_craft::document::value::TaggedValue;
88
use graph_craft::document::{NodeId, NodeInput};
99
use graph_craft::{ProtoNodeIdentifier, concrete};
@@ -13,7 +13,8 @@ use graphene_std::raster::BlendMode;
1313
use graphene_std::raster_types::{CPU, Raster};
1414
use graphene_std::subpath::Subpath;
1515
use graphene_std::table::Table;
16-
use graphene_std::text::{Font, TypesettingConfig};
16+
use graphene_std::text::{Font, TextAnchor, TypesettingConfig};
17+
use graphene_std::transform::Transform as _;
1718
use graphene_std::vector::Vector;
1819
use graphene_std::vector::style::{Fill, Stroke};
1920
use graphene_std::vector::{PointId, VectorModificationType};
@@ -289,6 +290,82 @@ impl<'a> ModifyInputsContext<'a> {
289290
self.network_interface.move_node_to_chain_start(&fill_id, layer, &[], self.import);
290291
}
291292

293+
pub fn insert_text_on_path(
294+
&mut self,
295+
text: String,
296+
font: Font,
297+
font_size: f64,
298+
character_spacing: f64,
299+
path_subpaths: Vec<Subpath<PointId>>,
300+
start_offset: f64,
301+
text_anchor: TextAnchor,
302+
side: graphene_std::text::TextPathSide,
303+
method: graphene_std::text::TextPathMethod,
304+
spacing: graphene_std::text::TextPathSpacing,
305+
text_length: Option<f64>,
306+
length_adjust: graphene_std::text::LengthAdjust,
307+
transform: DAffine2,
308+
layer: LayerNodeIdentifier,
309+
) {
310+
let path_vector = Table::new_from_element(Vector::from_subpaths(path_subpaths, true));
311+
let text_on_path_node = resolve_proto_node_type(graphene_std::text::text_on_path::IDENTIFIER)
312+
.expect("Text On Path node does not exist")
313+
.node_template_input_override([
314+
Some(NodeInput::scope("editor-api")),
315+
Some(NodeInput::value(TaggedValue::String(text), false)),
316+
Some(NodeInput::value(TaggedValue::Vector(path_vector), false)),
317+
Some(NodeInput::value(TaggedValue::Font(font), false)),
318+
Some(NodeInput::value(TaggedValue::F64(font_size), false)),
319+
Some(NodeInput::value(TaggedValue::F64(character_spacing), false)),
320+
Some(NodeInput::value(TaggedValue::F64(start_offset), false)),
321+
Some(NodeInput::value(TaggedValue::Bool(false), false)),
322+
Some(NodeInput::value(TaggedValue::TextPathSide(side), false)),
323+
Some(NodeInput::value(TaggedValue::TextAnchor(text_anchor), false)),
324+
Some(NodeInput::value(TaggedValue::TextPathMethod(method), false)),
325+
Some(NodeInput::value(TaggedValue::TextPathSpacing(spacing), false)),
326+
Some(NodeInput::value(TaggedValue::Bool(text_length.is_some()), false)),
327+
Some(NodeInput::value(TaggedValue::F64(text_length.unwrap_or(0.0)), false)),
328+
Some(NodeInput::value(TaggedValue::LengthAdjust(length_adjust), false)),
329+
Some(NodeInput::value(TaggedValue::Bool(false), false)),
330+
Some(NodeInput::value(TaggedValue::F64(0.0), false)),
331+
Some(NodeInput::value(TaggedValue::Bool(false), false)),
332+
]);
333+
334+
let text_on_path_id = NodeId::new();
335+
self.network_interface.insert_node(text_on_path_id, text_on_path_node, &[]);
336+
self.network_interface.move_node_to_chain_start(&text_on_path_id, layer, &[], self.import);
337+
338+
let (rotation, scale, skew): (f64, DVec2, f64) = transform.decompose_rotation_scale_skew();
339+
let translation = transform.translation;
340+
let rotation = rotation.to_degrees();
341+
let skew = DVec2::new(skew.atan().to_degrees(), 0.);
342+
343+
let transform_node = resolve_network_node_type("Transform").expect("Transform node does not exist").node_template_input_override([
344+
None,
345+
Some(NodeInput::value(TaggedValue::DVec2(translation), false)),
346+
Some(NodeInput::value(TaggedValue::F64(rotation), false)),
347+
Some(NodeInput::value(TaggedValue::DVec2(scale), false)),
348+
Some(NodeInput::value(TaggedValue::DVec2(skew), false)),
349+
]);
350+
let transform_id = NodeId::new();
351+
self.network_interface.insert_node(transform_id, transform_node, &[]);
352+
self.network_interface.move_node_to_chain_start(&transform_id, layer, &[], self.import);
353+
354+
let stroke = resolve_proto_node_type(graphene_std::vector_nodes::stroke::IDENTIFIER)
355+
.expect("Stroke node does not exist")
356+
.default_node_template();
357+
let stroke_id = NodeId::new();
358+
self.network_interface.insert_node(stroke_id, stroke, &[]);
359+
self.network_interface.move_node_to_chain_start(&stroke_id, layer, &[], self.import);
360+
361+
let fill = resolve_proto_node_type(graphene_std::vector_nodes::fill::IDENTIFIER)
362+
.expect("Fill node does not exist")
363+
.default_node_template();
364+
let fill_id = NodeId::new();
365+
self.network_interface.insert_node(fill_id, fill, &[]);
366+
self.network_interface.move_node_to_chain_start(&fill_id, layer, &[], self.import);
367+
}
368+
292369
pub fn insert_image_data(&mut self, image_frame: Table<Raster<CPU>>, layer: LayerNodeIdentifier) {
293370
let transform = resolve_network_node_type("Transform").expect("Transform node does not exist").default_node_template();
294371
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: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,11 @@ 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),
278+
TextPathMethod(text_nodes::text_on_path::TextPathMethod),
279+
TextPathSpacing(text_nodes::text_on_path::TextPathSpacing),
280+
LengthAdjust(text_nodes::text_on_path::LengthAdjust),
276281
ScaleType(core_types::transform::ScaleType),
277282
}
278283

node-graph/libraries/graphic-types/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ pub mod migrations {
7777
segment_domain: old.segment_domain,
7878
region_domain: old.region_domain,
7979
upstream_data: old.upstream_graphic_group,
80+
text_on_path_metadata: None,
8081
});
8182
*vector_table.iter_mut().next().unwrap().transform = old.transform;
8283
*vector_table.iter_mut().next().unwrap().alpha_blending = old.alpha_blending;

0 commit comments

Comments
 (0)