@@ -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
0 commit comments