@@ -5,7 +5,8 @@ use kurbo::{BezPath, ParamCurve, ParamCurveArclen, ParamCurveDeriv, PathEl, Path
55use parley:: PositionedLayoutItem ;
66use skrifa:: MetadataProvider ;
77use skrifa:: raw:: FontRef as ReadFontsRef ;
8- use vector_types:: Vector ;
8+ use std:: sync:: Arc ;
9+ use vector_types:: { TextOnPathMetadata , Vector } ;
910
1011#[ derive( Debug , Default , Clone , Copy , PartialEq , Hash , serde:: Serialize , serde:: Deserialize , DynAny , node_macro:: ChoiceType ) ]
1112pub enum TextPathSide {
@@ -22,6 +23,27 @@ pub enum TextAnchor {
2223 End ,
2324}
2425
26+ #[ derive( Debug , Default , Clone , Copy , PartialEq , Hash , serde:: Serialize , serde:: Deserialize , DynAny , node_macro:: ChoiceType ) ]
27+ pub enum TextPathMethod {
28+ #[ default]
29+ Align ,
30+ Stretch ,
31+ }
32+
33+ #[ derive( Debug , Default , Clone , Copy , PartialEq , Hash , serde:: Serialize , serde:: Deserialize , DynAny , node_macro:: ChoiceType ) ]
34+ pub enum TextPathSpacing {
35+ #[ default]
36+ Exact ,
37+ Auto ,
38+ }
39+
40+ #[ derive( Debug , Default , Clone , Copy , PartialEq , Hash , serde:: Serialize , serde:: Deserialize , DynAny , node_macro:: ChoiceType ) ]
41+ pub enum LengthAdjust {
42+ #[ default]
43+ Spacing ,
44+ SpacingAndGlyphs ,
45+ }
46+
2547pub struct ArcLengthLut {
2648 lengths : Vec < f64 > ,
2749 params : Vec < ( usize , f64 ) > ,
@@ -174,26 +196,29 @@ fn maybe_reverse_path(path: BezPath, side: TextPathSide) -> BezPath {
174196 }
175197}
176198
177- fn is_glyph_hidden ( mid : f64 , start_offset : f64 , total_length : f64 , is_closed : bool , text_anchor : TextAnchor ) -> bool {
178- if !is_closed {
179- return mid < 0.0 || mid > total_length;
180- }
199+ fn is_glyph_hidden ( mid : f64 , start_offset : f64 , total_length : f64 , is_closed : bool , text_anchor : TextAnchor , rtl : bool ) -> bool {
200+ if !is_closed { return mid < 0.0 || mid > total_length; }
181201 let d = mid - start_offset;
182- match text_anchor {
202+ let effective_anchor = if rtl { match text_anchor { TextAnchor :: Start => TextAnchor :: End , TextAnchor :: End => TextAnchor :: Start , _ => text_anchor } } else { text_anchor } ;
203+ match effective_anchor {
183204 TextAnchor :: Start => d < 0.0 || d > total_length,
184205 TextAnchor :: Middle => d < -total_length / 2.0 || d > total_length / 2.0 ,
185206 TextAnchor :: End => d < -total_length || d > 0.0 ,
186207 }
187208}
188209
189210fn resolve_startpoint ( abs_offset : f64 , total_advance : f64 , text_anchor : TextAnchor ) -> f64 {
190- match text_anchor {
191- TextAnchor :: Start => abs_offset,
192- TextAnchor :: Middle => abs_offset - total_advance / 2.0 ,
193- TextAnchor :: End => abs_offset - total_advance,
194- }
211+ match text_anchor { TextAnchor :: Start => abs_offset, TextAnchor :: Middle => abs_offset - total_advance / 2.0 , TextAnchor :: End => abs_offset - total_advance }
212+ }
213+
214+ fn curvature_spacing_adjustment ( lut : & ArcLengthLut , mid : f64 , advance : f64 ) -> f64 {
215+ let half = advance / 2.0 ;
216+ let ( _, a0) = at_with_extension ( lut, mid - half) ;
217+ let ( _, a1) = at_with_extension ( lut, mid + half) ;
218+ advance * ( a1 - a0) . abs ( ) * 0.1
195219}
196220
221+ #[ allow( clippy:: too_many_arguments) ]
197222pub fn place_text_on_path < Upstream : Default + ' static > (
198223 text : & str ,
199224 path_table : & Table < Vector < Upstream > > ,
@@ -204,17 +229,25 @@ pub fn place_text_on_path<Upstream: Default + 'static>(
204229 start_offset_percent : bool ,
205230 side : TextPathSide ,
206231 text_anchor : TextAnchor ,
232+ method : TextPathMethod ,
233+ spacing : TextPathSpacing ,
234+ text_length : Option < f64 > ,
235+ length_adjust : LengthAdjust ,
236+ path_length : Option < f64 > ,
237+ rtl : bool ,
207238 font_cache : & crate :: FontCache ,
208239) -> Table < Vector < Upstream > > {
209- let Some ( path_row) = path_table. iter ( ) . next ( ) else { return Table :: new ( ) } ;
210- let bezpath = path_row. element . stroke_bezpath_iter ( ) . find ( |path| path. segments ( ) . next ( ) . is_some ( ) ) ;
211- let Some ( bezpath) = bezpath else { return Table :: new ( ) } ;
240+ // TODO: Support method="stretch" (warp glyph outlines along path perpendiculars)
241+ if method == TextPathMethod :: Stretch {
242+ log:: warn!( "textPath method='stretch' is not yet implemented; falling back to 'align'" ) ;
243+ }
244+
245+ let Some ( original_bezpath) = path_table. iter ( ) . next ( ) . and_then ( |row| row. element . stroke_bezpath_iter ( ) . find ( |p| p. segments ( ) . next ( ) . is_some ( ) ) ) else { return Table :: new ( ) } ;
246+ let path_d_for_export = original_bezpath. to_svg ( ) ;
212247
213- let bezpath = maybe_reverse_path ( bezpath , side) ;
248+ let bezpath = maybe_reverse_path ( original_bezpath , side) ;
214249 let lut = ArcLengthLut :: build ( & bezpath, 100 ) ;
215- if lut. total_length < 1e-9 {
216- return Table :: new ( ) ;
217- }
250+ if lut. total_length < 1e-9 { return Table :: new ( ) ; }
218251
219252 let typesetting = crate :: TypesettingConfig {
220253 font_size,
@@ -225,13 +258,30 @@ pub fn place_text_on_path<Upstream: Default + 'static>(
225258 let layout = crate :: TextContext :: with_thread_local ( |ctx| ctx. layout_text ( text, font, font_cache, typesetting) ) ;
226259 let Some ( layout) = layout else { return Table :: new ( ) } ;
227260
228- let abs_offset = if start_offset_percent { start_offset * lut. total_length } else { start_offset } ;
261+ let mut abs_offset = if start_offset_percent { start_offset * lut. total_length } else { start_offset } ;
262+ if let Some ( author_length) = path_length. filter ( |& l| l > 1e-9 ) { abs_offset *= lut. total_length / author_length; }
229263
230264 let mut path_builder = crate :: path_builder:: PathBuilder :: new ( true , layout. scale ( ) as f64 ) ;
231265
232266 layout. lines ( ) . for_each ( |line| {
233267 let line_width = line. metrics ( ) . advance as f64 ;
234- let line_start = resolve_startpoint ( abs_offset, line_width, text_anchor) ;
268+
269+ let glyph_count: usize = line. items ( ) . map ( |item| if let PositionedLayoutItem :: GlyphRun ( gr) = item { gr. glyphs ( ) . count ( ) } else { 0 } ) . sum ( ) ;
270+
271+ let ( advance_scale, spacing_delta) = if let Some ( target) = text_length. filter ( |& t| t > 0.0 && line_width > 1e-9 ) {
272+ match length_adjust {
273+ LengthAdjust :: Spacing => ( 1.0 , ( target - line_width) / glyph_count. saturating_sub ( 1 ) . max ( 1 ) as f64 ) ,
274+ LengthAdjust :: SpacingAndGlyphs => ( target / line_width, 0.0 ) ,
275+ }
276+ } else {
277+ ( 1.0 , 0.0 )
278+ } ;
279+
280+ let effective_line_width = line_width * advance_scale + spacing_delta * glyph_count. saturating_sub ( 1 ) as f64 ;
281+ let line_start = resolve_startpoint ( abs_offset, effective_line_width, text_anchor) ;
282+
283+ let mut cumulative_offset = 0.0_f64 ;
284+ let mut glyph_index = 0_usize ;
235285
236286 line. items ( ) . for_each ( |item| {
237287 if let PositionedLayoutItem :: GlyphRun ( glyph_run) = item {
@@ -244,12 +294,16 @@ pub fn place_text_on_path<Upstream: Default + 'static>(
244294 let outlines = ReadFontsRef :: from_index ( font. data . as_ref ( ) , font. index ) . unwrap ( ) . outline_glyphs ( ) ;
245295
246296 glyph_run. glyphs ( ) . for_each ( |glyph| {
247- let glyph_path_pos = line_start + run_x as f64 ;
248- let mid = glyph_path_pos + glyph. advance as f64 / 2.0 ;
297+ let raw_advance = glyph. advance as f64 * advance_scale;
298+ cumulative_offset += if glyph_index > 0 { spacing_delta } else { 0.0 } ;
299+ let mid = line_start + run_x as f64 * advance_scale + cumulative_offset - glyph_run. offset ( ) as f64 * advance_scale + raw_advance / 2.0 ;
300+ let adjusted_mid = mid + if spacing == TextPathSpacing :: Auto { curvature_spacing_adjustment ( & lut, mid, raw_advance) } else { 0.0 } ;
301+
249302 run_x += glyph. advance ;
303+ glyph_index += 1 ;
250304
251- if !is_glyph_hidden ( mid , abs_offset, lut. total_length , lut. is_closed , text_anchor) {
252- let effective_mid = if lut. is_closed { mid . rem_euclid ( lut. total_length ) } else { mid } ;
305+ if !is_glyph_hidden ( adjusted_mid , abs_offset, lut. total_length , lut. is_closed , text_anchor, rtl ) {
306+ let effective_mid = if lut. is_closed { adjusted_mid . rem_euclid ( lut. total_length ) } else { adjusted_mid } ;
253307 let ( point, angle) = if lut. is_closed { lut. at_or_zero ( effective_mid) } else { at_with_extension ( & lut, effective_mid) } ;
254308
255309 if let Some ( glyph_outline) = outlines. get ( skrifa:: GlyphId :: from ( glyph. id ) ) {
@@ -259,10 +313,34 @@ pub fn place_text_on_path<Upstream: Default + 'static>(
259313 path_builder. draw_glyph ( & glyph_outline, font_size, & normalized_coords, style_skew, final_transform, true ) ;
260314 }
261315 }
316+
317+ cumulative_offset += raw_advance - glyph. advance as f64 ;
262318 } ) ;
263319 }
264320 } ) ;
265321 } ) ;
266322
267- path_builder. finalize ( )
323+ let mut result = path_builder. finalize ( ) ;
324+
325+ // Attach text-on-path metadata so SVG export can emit <text><textPath> instead of raw outlines
326+ let metadata = Arc :: new ( TextOnPathMetadata {
327+ text : text. to_string ( ) ,
328+ font_family : font. font_family . clone ( ) ,
329+ font_style : font. font_style . clone ( ) ,
330+ font_size,
331+ path_d : path_d_for_export,
332+ start_offset,
333+ start_offset_percent,
334+ text_anchor : match text_anchor { TextAnchor :: Start => "start" , TextAnchor :: Middle => "middle" , TextAnchor :: End => "end" } . to_string ( ) ,
335+ side : match side { TextPathSide :: Left => "left" , TextPathSide :: Right => "right" } . to_string ( ) ,
336+ method : match method { TextPathMethod :: Align => "align" , TextPathMethod :: Stretch => "stretch" } . to_string ( ) ,
337+ spacing : match spacing { TextPathSpacing :: Exact => "exact" , TextPathSpacing :: Auto => "auto" } . to_string ( ) ,
338+ text_length,
339+ length_adjust : match length_adjust { LengthAdjust :: Spacing => "spacing" , LengthAdjust :: SpacingAndGlyphs => "spacingAndGlyphs" } . to_string ( ) ,
340+ } ) ;
341+ for row in result. iter_mut ( ) {
342+ row. element . text_on_path_metadata = Some ( Arc :: clone ( & metadata) ) ;
343+ }
344+
345+ result
268346}
0 commit comments