Skip to content

Commit ef54a07

Browse files
committed
SVG textPath compliance: enums, rendering logic, SVG export elements, and XML import pre-parse
1 parent a558fb8 commit ef54a07

10 files changed

Lines changed: 422 additions & 24 deletions

File tree

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

Lines changed: 105 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,9 @@ impl MessageHandler<GraphOperationMessage, GraphOperationMessageContext<'_>> for
430430

431431
let graphite_gradient_stops = extract_graphite_gradient_stops(&svg);
432432

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+
433436
// Pass identity so each leaf layer receives only its SVG-native transform from `abs_transform`.
434437
// The placement offset is then applied once to the root group layer below.
435438
import_usvg_node(
@@ -439,6 +442,7 @@ impl MessageHandler<GraphOperationMessage, GraphOperationMessageContext<'_>> for
439442
parent,
440443
insert_index,
441444
&graphite_gradient_stops,
445+
&textpath_attrs,
442446
);
443447

444448
// After import, `layer_node` is set to the root group. Apply the placement transform to it
@@ -538,6 +542,76 @@ fn parse_hex_stop_color(hex: &str, opacity: f32) -> Option<Color> {
538542
Some(Color::from_rgbaf32_unchecked(r, g, b, opacity))
539543
}
540544

545+
/// Attributes from `<textPath>` elements that `usvg` does not expose.
546+
/// Keyed by the parent `<text>` element's `id` attribute (may be empty string if no id).
547+
/// For simple SVGs with one `<text>` element, the key will be "".
548+
#[derive(Debug, Default, Clone)]
549+
struct TextPathAttrs {
550+
/// SVG `method` attribute: "align" (default) | "stretch"
551+
pub method: Option<String>,
552+
/// SVG `spacing` attribute: "exact" (default) | "auto"
553+
pub spacing: Option<String>,
554+
/// SVG `side` attribute: "left" (default) | "right"
555+
pub side: Option<String>,
556+
/// SVG `textLength` attribute in user units
557+
pub text_length: Option<f64>,
558+
/// SVG `lengthAdjust` attribute: "spacing" | "spacingAndGlyphs"
559+
pub length_adjust: Option<String>,
560+
}
561+
562+
/// Scan the raw SVG string for `<textPath>` elements and extract the attributes that
563+
/// `usvg` does not expose. Returns a map from parent `<text>` id → attrs.
564+
/// Uses simple byte-level scanning instead of a full XML parser to avoid new dependencies.
565+
fn pre_parse_textpath_attrs(svg: &str) -> std::collections::HashMap<String, TextPathAttrs> {
566+
/// Extract the value of `attr="..."` or `attr='...'` from a raw attribute string.
567+
fn get_attr<'a>(attrs_str: &'a str, name: &str) -> Option<&'a str> {
568+
// Try double-quote then single-quote form
569+
for q in ['"', '\'' ] {
570+
let pattern = format!("{name}={q}");
571+
if let Some(start) = attrs_str.find(&pattern) {
572+
let rest = &attrs_str[start + pattern.len()..];
573+
if let Some(end) = rest.find(q) {
574+
return Some(&rest[..end]);
575+
}
576+
}
577+
}
578+
None
579+
}
580+
581+
let mut map = std::collections::HashMap::new();
582+
let mut search = svg;
583+
584+
while let Some(text_start) = search.find("<text") {
585+
search = &search[text_start..];
586+
587+
// Find the end of the opening <text ...> tag
588+
let tag_end = search.find('>').unwrap_or(search.len());
589+
let text_tag = &search[..=tag_end];
590+
591+
// Extract the id of this <text> element
592+
let text_id = get_attr(text_tag, "id").unwrap_or("").to_string();
593+
594+
// Advance past this tag and look for a nested <textPath ...>
595+
search = &search[tag_end.saturating_add(1).min(search.len())..];
596+
597+
if let Some(tp_start) = search.find("<textPath") {
598+
let tp_end = search[tp_start..].find('>').map(|e| tp_start + e).unwrap_or(search.len());
599+
let tp_tag = &search[tp_start..=tp_end];
600+
601+
let attrs = TextPathAttrs {
602+
method: get_attr(tp_tag, "method").map(str::to_string),
603+
spacing: get_attr(tp_tag, "spacing").map(str::to_string),
604+
side: get_attr(tp_tag, "side").map(str::to_string),
605+
text_length: get_attr(tp_tag, "textLength").and_then(|v| v.parse().ok()),
606+
length_adjust: get_attr(tp_tag, "lengthAdjust").map(str::to_string),
607+
};
608+
map.insert(text_id, attrs);
609+
}
610+
}
611+
612+
map
613+
}
614+
541615
/// Import a usvg node as the root of an SVG import operation.
542616
///
543617
/// The root layer uses the full `move_layer_to_stack` (with push/collision logic) to correctly
@@ -551,6 +625,7 @@ fn import_usvg_node(
551625
parent: LayerNodeIdentifier,
552626
insert_index: usize,
553627
graphite_gradient_stops: &HashMap<String, GradientStops>,
628+
textpath_attrs: &HashMap<String, TextPathAttrs>,
554629
) {
555630
let layer = modify_inputs.create_layer(id);
556631

@@ -571,7 +646,7 @@ fn import_usvg_node(
571646
modify_inputs.import = true;
572647

573648
for child in group.children() {
574-
let extent = import_usvg_node_inner(modify_inputs, child, NodeId::new(), layer, 0, graphite_gradient_stops, &mut group_extents_map);
649+
let extent = import_usvg_node_inner(modify_inputs, child, NodeId::new(), layer, 0, graphite_gradient_stops, &mut group_extents_map, textpath_attrs);
575650
child_extents_svg_order.push(extent);
576651
}
577652

@@ -596,7 +671,7 @@ fn import_usvg_node(
596671
warn!("Skip image");
597672
}
598673
usvg::Node::Text(text) => {
599-
import_usvg_text(modify_inputs, text, node.abs_transform(), layer, parent, insert_index);
674+
import_usvg_text(modify_inputs, text, node.abs_transform(), layer, parent, insert_index, textpath_attrs);
600675
}
601676
}
602677
}
@@ -614,6 +689,7 @@ fn import_usvg_node_inner(
614689
insert_index: usize,
615690
graphite_gradient_stops: &HashMap<String, GradientStops>,
616691
group_extents_map: &mut HashMap<LayerNodeIdentifier, Vec<u32>>,
692+
textpath_attrs: &HashMap<String, TextPathAttrs>,
617693
) -> u32 {
618694
let layer = modify_inputs.create_layer(id);
619695
modify_inputs.network_interface.move_layer_to_stack_for_import(layer, parent, insert_index, &[]);
@@ -623,7 +699,7 @@ fn import_usvg_node_inner(
623699
usvg::Node::Group(group) => {
624700
let mut child_extents: Vec<u32> = Vec::new();
625701
for child in group.children() {
626-
let extent = import_usvg_node_inner(modify_inputs, child, NodeId::new(), layer, 0, graphite_gradient_stops, group_extents_map);
702+
let extent = import_usvg_node_inner(modify_inputs, child, NodeId::new(), layer, 0, graphite_gradient_stops, group_extents_map, textpath_attrs);
627703
child_extents.push(extent);
628704
}
629705
modify_inputs.layer_node = Some(layer);
@@ -642,7 +718,7 @@ fn import_usvg_node_inner(
642718
0
643719
}
644720
usvg::Node::Text(text) => {
645-
import_usvg_text(modify_inputs, text, node.abs_transform(), layer, parent, insert_index);
721+
import_usvg_text(modify_inputs, text, node.abs_transform(), layer, parent, insert_index, textpath_attrs);
646722
0
647723
}
648724
usvg::Node::Path(path) => {
@@ -675,8 +751,12 @@ fn import_usvg_path(modify_inputs: &mut ModifyInputsContext, node: &usvg::Node,
675751
}
676752
}
677753

678-
fn import_usvg_text(modify_inputs: &mut ModifyInputsContext, text: &usvg::Text, transform: usvg::Transform, layer: LayerNodeIdentifier, parent: LayerNodeIdentifier, insert_index: usize) {
679-
use graphene_std::text::TextAnchor;
754+
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>) {
755+
use graphene_std::text::{LengthAdjust, TextAnchor, TextPathMethod, TextPathSide, TextPathSpacing};
756+
757+
// Look up pre-parsed textPath attrs using the <text> element id (or "" for unidentified elements)
758+
let text_id = text.id();
759+
let tp_attrs = textpath_attrs.get(text_id).cloned().unwrap_or_default();
680760

681761
for (i, chunk) in text.chunks().iter().enumerate() {
682762
let current_layer = if i == 0 {
@@ -717,7 +797,25 @@ fn import_usvg_text(modify_inputs: &mut ModifyInputsContext, text: &usvg::Text,
717797
transform.tx as f64,
718798
transform.ty as f64,
719799
]);
720-
modify_inputs.insert_text_on_path(chunk.text().to_string(), font, font_size, letter_spacing, path_subpaths, start_offset, anchor, affine, current_layer);
800+
// Translate pre-parsed attrs to typed enums
801+
let method = match tp_attrs.method.as_deref() {
802+
Some("stretch") => TextPathMethod::Stretch,
803+
_ => TextPathMethod::Align,
804+
};
805+
let spacing = match tp_attrs.spacing.as_deref() {
806+
Some("auto") => TextPathSpacing::Auto,
807+
_ => TextPathSpacing::Exact,
808+
};
809+
let side = match tp_attrs.side.as_deref() {
810+
Some("right") => TextPathSide::Right,
811+
_ => TextPathSide::Left,
812+
};
813+
let length_adjust = match tp_attrs.length_adjust.as_deref() {
814+
Some("spacingAndGlyphs") => LengthAdjust::SpacingAndGlyphs,
815+
_ => LengthAdjust::Spacing,
816+
};
817+
818+
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);
721819
modify_inputs.fill_set(Fill::Solid(Color::BLACK));
722820
} else {
723821
// Regular text fallback

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

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,11 @@ impl<'a> ModifyInputsContext<'a> {
299299
path_subpaths: Vec<Subpath<PointId>>,
300300
start_offset: f64,
301301
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,
302307
transform: DAffine2,
303308
layer: LayerNodeIdentifier,
304309
) {
@@ -307,6 +312,9 @@ impl<'a> ModifyInputsContext<'a> {
307312
// Create the path vector object
308313
let path_vector = Table::new_from_element(Vector::from_subpaths(path_subpaths, true));
309314

315+
let has_text_length = text_length.is_some();
316+
let text_length_val = text_length.unwrap_or(0.0);
317+
310318
// Create the Text On Path node directly using the vector as a value input
311319
let text_on_path_node = resolve_proto_node_type(graphene_std::text::text_on_path::IDENTIFIER)
312320
.expect("Text On Path node does not exist")
@@ -318,9 +326,17 @@ impl<'a> ModifyInputsContext<'a> {
318326
Some(NodeInput::value(TaggedValue::F64(font_size), false)),
319327
Some(NodeInput::value(TaggedValue::F64(character_spacing), false)),
320328
Some(NodeInput::value(TaggedValue::F64(start_offset), false)), // start_offset
321-
Some(NodeInput::value(TaggedValue::Bool(false), false)), // start_offset_percent
322-
Some(NodeInput::value(TaggedValue::TextPathSide(TextPathSide::Left), false)),
329+
Some(NodeInput::value(TaggedValue::Bool(false), false)), // start_offset_percent
330+
Some(NodeInput::value(TaggedValue::TextPathSide(side), false)),
323331
Some(NodeInput::value(TaggedValue::TextAnchor(text_anchor), false)),
332+
Some(NodeInput::value(TaggedValue::TextPathMethod(method), false)),
333+
Some(NodeInput::value(TaggedValue::TextPathSpacing(spacing), false)),
334+
Some(NodeInput::value(TaggedValue::Bool(has_text_length), false)), // has_text_length
335+
Some(NodeInput::value(TaggedValue::F64(text_length_val), false)), // text_length
336+
Some(NodeInput::value(TaggedValue::LengthAdjust(length_adjust), false)),
337+
Some(NodeInput::value(TaggedValue::Bool(false), false)), // has_path_length
338+
Some(NodeInput::value(TaggedValue::F64(0.0), false)), // path_length
339+
Some(NodeInput::value(TaggedValue::Bool(false), false)), // rtl
324340
]);
325341

326342
let text_on_path_id = NodeId::new();

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,9 @@ tagged_value! {
275275
TextAlign(text_nodes::TextAlign),
276276
TextPathSide(text_nodes::text_on_path::TextPathSide),
277277
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),
278281
ScaleType(core_types::transform::ScaleType),
279282
}
280283

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;

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

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -787,6 +787,64 @@ impl Render for Table<Graphic> {
787787
impl Render for Table<Vector> {
788788
fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) {
789789
for row in self.iter() {
790+
// SVG export: emit <text><textPath> instead of raw glyph outlines
791+
if render_params.for_export {
792+
if let Some(ref meta) = row.element.text_on_path_metadata {
793+
let path_id = format!("textpath-{}", generate_uuid());
794+
795+
// Store the reference path in <defs>
796+
write!(
797+
&mut render.svg_defs,
798+
r#"<path id="{path_id}" d="{}" fill="none"/>"#,
799+
meta.path_d
800+
)
801+
.unwrap();
802+
803+
// Build font CSS style string
804+
let font_style_css = format!(
805+
"font-family: {}; font-size: {}px; font-style: {};",
806+
meta.font_family, meta.font_size, meta.font_style
807+
);
808+
809+
// Build startOffset attribute value
810+
let start_offset_attr = if meta.start_offset_percent {
811+
format!("{}%", meta.start_offset * 100.0)
812+
} else {
813+
format!("{}", meta.start_offset)
814+
};
815+
816+
// Compose the full <text> element with a <textPath> child
817+
let matrix = format_transform_matrix(*row.transform);
818+
let transform_attr = if matrix.is_empty() {
819+
String::new()
820+
} else {
821+
format!(r#" transform="{matrix}""#)
822+
};
823+
824+
let text_length_attr = meta
825+
.text_length
826+
.map(|tl| {
827+
let length_adjust = &meta.length_adjust;
828+
format!(r#" textLength="{tl}" lengthAdjust="{length_adjust}""#)
829+
})
830+
.unwrap_or_default();
831+
832+
// Only emit side as xlink:href reversal note — side="right" means path was reversed internally
833+
let side_attr = if meta.side == "right" { r#" side="right""# } else { "" };
834+
835+
// text-anchor maps to SVG text-anchor property
836+
let anchor_style = format!("text-anchor: {};", meta.text_anchor);
837+
838+
let method = &meta.method;
839+
let spacing = &meta.spacing;
840+
let text = &meta.text;
841+
render.leaf_node(format!(
842+
r##"<text style="{font_style_css} {anchor_style}"{transform_attr}><textPath href="#{path_id}" startOffset="{start_offset_attr}" method="{method}" spacing="{spacing}"{side_attr}{text_length_attr}>{text}</textPath></text>"##,
843+
));
844+
continue;
845+
}
846+
}
847+
790848
let multiplied_transform = *row.transform;
791849
let vector = &row.element;
792850
// Only consider strokes with non-zero weight, since default strokes with zero weight would prevent assigning the correct stroke transform

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ pub use math::{QuadExt, RectExt};
1313
pub use subpath::Subpath;
1414
pub use vector::Vector;
1515
pub use vector::reference_point::ReferencePoint;
16+
pub use vector::TextOnPathMetadata;
1617

1718
// Re-export dependencies that users of this crate will need
1819
pub use dyn_any;

node-graph/libraries/vector-types/src/vector/vector_types.rs

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,33 @@ use dyn_any::StaticType;
1414
use glam::{DAffine2, DVec2};
1515
use kurbo::{Affine, BezPath, Rect, Shape};
1616
use std::collections::HashMap;
17+
use std::sync::Arc;
18+
19+
/// Metadata carried by a text-on-path `Vector` to enable lossless SVG `<textPath>` export.
20+
/// When present on the first row of a `Table<Vector>`, the SVG renderer emits
21+
/// `<text><textPath href="...">` instead of raw `<path>` outlines.
22+
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
23+
pub struct TextOnPathMetadata {
24+
pub text: String,
25+
pub font_family: String,
26+
pub font_style: String,
27+
pub font_size: f64,
28+
/// SVG path `d` attribute string for the reference path.
29+
pub path_d: String,
30+
pub start_offset: f64,
31+
pub start_offset_percent: bool,
32+
/// "start" | "middle" | "end"
33+
pub text_anchor: String,
34+
/// "left" | "right"
35+
pub side: String,
36+
/// "align" | "stretch"
37+
pub method: String,
38+
/// "exact" | "auto"
39+
pub spacing: String,
40+
pub text_length: Option<f64>,
41+
/// "spacing" | "spacingAndGlyphs"
42+
pub length_adjust: String,
43+
}
1744

1845
/// Represents vector graphics data, composed of Bézier curves in a path or mesh arrangement.
1946
///
@@ -36,6 +63,11 @@ pub struct Vector<Upstream> {
3663
/// 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.
3764
#[serde(alias = "upstream_group")]
3865
pub upstream_data: Upstream,
66+
67+
/// When set, this vector was produced by a text-on-path node. SVG export uses this metadata
68+
/// to emit a `<text><textPath>` element instead of raw path outlines.
69+
#[serde(default, skip_serializing_if = "Option::is_none")]
70+
pub text_on_path_metadata: Option<Arc<TextOnPathMetadata>>,
3971
}
4072
unsafe impl<Upstream: 'static> StaticType for Vector<Upstream> {
4173
type Static = Self;
@@ -50,6 +82,7 @@ impl<Upstream: Default + 'static> Default for Vector<Upstream> {
5082
segment_domain: SegmentDomain::new(),
5183
region_domain: RegionDomain::new(),
5284
upstream_data: Upstream::default(),
85+
text_on_path_metadata: None,
5386
}
5487
}
5588
}
@@ -61,7 +94,7 @@ impl<Upstream> std::hash::Hash for Vector<Upstream> {
6194
self.region_domain.hash(state);
6295
self.style.hash(state);
6396
self.colinear_manipulators.hash(state);
64-
// We don't hash the upstream_data intentionally
97+
// We don't hash upstream_data or text_on_path_metadata intentionally
6598
}
6699
}
67100

0 commit comments

Comments
 (0)