Skip to content

Commit 92f0038

Browse files
committed
Implement raw XML pre-parsing for SVG textPath import
1 parent 54a75c0 commit 92f0038

2 files changed

Lines changed: 212 additions & 18 deletions

File tree

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

Lines changed: 133 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 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+
&textpath_attrs,
436446
);
437447

438448
// After import, `layer_node` is set to the root group. Apply the placement transform to it
@@ -532,6 +542,55 @@ 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, TextPathAttrs> {
555+
fn get_attr<'a>(attrs_str: &'a str, name: &str) -> Option<&'a str> {
556+
['"', '\''].into_iter().find_map(|q| {
557+
let pattern = format!("{name}={q}");
558+
let rest = &attrs_str[attrs_str.find(&pattern)? + pattern.len()..];
559+
Some(&rest[..rest.find(q)?])
560+
})
561+
}
562+
563+
let mut map = std::collections::HashMap::new();
564+
let mut search = svg;
565+
566+
while let Some(text_start) = search.find("<text") {
567+
search = &search[text_start..];
568+
let tag_end = search.find('>').unwrap_or(search.len());
569+
let text_tag = &search[..=tag_end];
570+
let text_id = get_attr(text_tag, "id").unwrap_or("").to_string();
571+
572+
search = &search[tag_end.saturating_add(1).min(search.len())..];
573+
574+
if let Some(tp_start) = search.find("<textPath") {
575+
let tp_end = search[tp_start..].find('>').map(|e| tp_start + e).unwrap_or(search.len());
576+
let tp_tag = &search[tp_start..=tp_end];
577+
578+
map.insert(
579+
text_id,
580+
TextPathAttrs {
581+
method: get_attr(tp_tag, "method").map(str::to_string),
582+
spacing: get_attr(tp_tag, "spacing").map(str::to_string),
583+
side: get_attr(tp_tag, "side").map(str::to_string),
584+
text_length: get_attr(tp_tag, "textLength").and_then(|v| v.parse().ok()),
585+
length_adjust: get_attr(tp_tag, "lengthAdjust").map(str::to_string),
586+
},
587+
);
588+
}
589+
}
590+
591+
map
592+
}
593+
535594
/// Import a usvg node as the root of an SVG import operation.
536595
///
537596
/// The root layer uses the full `move_layer_to_stack` (with push/collision logic) to correctly
@@ -545,6 +604,7 @@ fn import_usvg_node(
545604
parent: LayerNodeIdentifier,
546605
insert_index: usize,
547606
graphite_gradient_stops: &HashMap<String, GradientStops>,
607+
textpath_attrs: &HashMap<String, TextPathAttrs>,
548608
) {
549609
let layer = modify_inputs.create_layer(id);
550610

@@ -565,7 +625,7 @@ fn import_usvg_node(
565625
modify_inputs.import = true;
566626

567627
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);
628+
let extent = import_usvg_node_inner(modify_inputs, child, NodeId::new(), layer, 0, graphite_gradient_stops, &mut group_extents_map, textpath_attrs);
569629
child_extents_svg_order.push(extent);
570630
}
571631

@@ -590,9 +650,7 @@ fn import_usvg_node(
590650
warn!("Skip image");
591651
}
592652
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));
653+
import_usvg_text(modify_inputs, text, node.abs_transform(), layer, parent, insert_index, textpath_attrs);
596654
}
597655
}
598656
}
@@ -610,6 +668,7 @@ fn import_usvg_node_inner(
610668
insert_index: usize,
611669
graphite_gradient_stops: &HashMap<String, GradientStops>,
612670
group_extents_map: &mut HashMap<LayerNodeIdentifier, Vec<u32>>,
671+
textpath_attrs: &HashMap<String, TextPathAttrs>,
613672
) -> u32 {
614673
let layer = modify_inputs.create_layer(id);
615674
modify_inputs.network_interface.move_layer_to_stack_for_import(layer, parent, insert_index, &[]);
@@ -619,7 +678,7 @@ fn import_usvg_node_inner(
619678
usvg::Node::Group(group) => {
620679
let mut child_extents: Vec<u32> = Vec::new();
621680
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);
681+
let extent = import_usvg_node_inner(modify_inputs, child, NodeId::new(), layer, 0, graphite_gradient_stops, group_extents_map, textpath_attrs);
623682
child_extents.push(extent);
624683
}
625684
modify_inputs.layer_node = Some(layer);
@@ -633,24 +692,21 @@ fn import_usvg_node_inner(
633692
group_extents_map.insert(layer, child_extents);
634693
total_extent
635694
}
636-
usvg::Node::Path(path) => {
637-
import_usvg_path(modify_inputs, node, path, layer, graphite_gradient_stops);
638-
0
639-
}
640695
usvg::Node::Image(_image) => {
641696
warn!("Skip image");
642697
0
643698
}
644699
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));
700+
import_usvg_text(modify_inputs, text, node.abs_transform(), layer, parent, insert_index, textpath_attrs);
701+
0
702+
}
703+
usvg::Node::Path(path) => {
704+
import_usvg_path(modify_inputs, node, path, layer, graphite_gradient_stops);
648705
0
649706
}
650707
}
651708
}
652709

653-
/// Helper to apply path data (vector geometry, fill, stroke, transform) to a layer.
654710
fn import_usvg_path(modify_inputs: &mut ModifyInputsContext, node: &usvg::Node, path: &usvg::Path, layer: LayerNodeIdentifier, graphite_gradient_stops: &HashMap<String, GradientStops>) {
655711
let subpaths = convert_usvg_path(path);
656712
let bounds = subpaths.iter().filter_map(|subpath| subpath.bounding_box()).reduce(Quad::combine_bounds).unwrap_or_default();
@@ -674,6 +730,67 @@ fn import_usvg_path(modify_inputs: &mut ModifyInputsContext, node: &usvg::Node,
674730
}
675731
}
676732

733+
fn import_usvg_text(modify_inputs: &mut ModifyInputsContext, text: &usvg::Text, transform: usvg::Transform, layer: LayerNodeIdentifier, parent: LayerNodeIdentifier, insert_index: usize, textpath_attrs: &HashMap<String, TextPathAttrs>) {
734+
use graphene_std::text::{LengthAdjust, TextAnchor, TextPathMethod, TextPathSide, TextPathSpacing};
735+
736+
// Look up pre-parsed textPath attrs using the <text> element id (or "" for unidentified elements)
737+
let text_id = text.id();
738+
let tp_attrs = textpath_attrs.get(text_id).cloned().unwrap_or_default();
739+
740+
for (i, chunk) in text.chunks().iter().enumerate() {
741+
let current_layer = if i == 0 {
742+
layer
743+
} else {
744+
let new_id = NodeId::new();
745+
let new_layer = modify_inputs.create_layer(new_id);
746+
modify_inputs.network_interface.move_layer_to_stack_for_import(new_layer, parent, insert_index, &[]);
747+
new_layer
748+
};
749+
modify_inputs.layer_node = Some(current_layer);
750+
751+
let font_family = chunk
752+
.spans()
753+
.first()
754+
.and_then(|span| span.font().families().first().map(|f| f.to_string()))
755+
.unwrap_or_else(|| graphene_std::consts::DEFAULT_FONT_FAMILY.to_string());
756+
let font_style = graphene_std::consts::DEFAULT_FONT_STYLE.to_string();
757+
let font = Font::new(font_family, font_style);
758+
759+
let font_size = chunk.spans().first().map(|s| s.font_size().get()).unwrap_or(24.0) as f64;
760+
let letter_spacing = chunk.spans().first().map(|s| s.letter_spacing()).unwrap_or(0.0) as f64;
761+
762+
if let usvg::TextFlow::Path(text_path) = chunk.text_flow() {
763+
let path_subpaths = convert_tiny_skia_path(text_path.path());
764+
let start_offset = text_path.start_offset() as f64;
765+
let anchor = match chunk.anchor() {
766+
usvg::TextAnchor::Start => TextAnchor::Start,
767+
usvg::TextAnchor::Middle => TextAnchor::Middle,
768+
usvg::TextAnchor::End => TextAnchor::End,
769+
};
770+
771+
let affine = DAffine2::from_cols_array(&[
772+
transform.sx as f64,
773+
transform.ky as f64,
774+
transform.kx as f64,
775+
transform.sy as f64,
776+
transform.tx as f64,
777+
transform.ty as f64,
778+
]);
779+
let method = if tp_attrs.method.as_deref() == Some("stretch") { TextPathMethod::Stretch } else { TextPathMethod::Align };
780+
let spacing = if tp_attrs.spacing.as_deref() == Some("auto") { TextPathSpacing::Auto } else { TextPathSpacing::Exact };
781+
let side = if tp_attrs.side.as_deref() == Some("right") { TextPathSide::Right } else { TextPathSide::Left };
782+
let length_adjust = if tp_attrs.length_adjust.as_deref() == Some("spacingAndGlyphs") { LengthAdjust::SpacingAndGlyphs } else { LengthAdjust::Spacing };
783+
784+
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);
785+
modify_inputs.fill_set(Fill::Solid(Color::BLACK));
786+
} else {
787+
// Regular text fallback
788+
modify_inputs.insert_text(chunk.text().to_string(), font, TypesettingConfig { font_size, ..Default::default() }, current_layer);
789+
modify_inputs.fill_set(Fill::Solid(Color::BLACK));
790+
}
791+
}
792+
}
793+
677794
/// Set correct positions for all imported layers in a single top-down O(n) pass.
678795
///
679796
/// 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)

0 commit comments

Comments
 (0)