1
1
use crate :: vector:: PointId ;
2
2
use bezier_rs:: { ManipulatorGroup , Subpath } ;
3
+ use dyn_any:: DynAny ;
3
4
use glam:: DVec2 ;
4
5
use rustybuzz:: ttf_parser:: { GlyphId , OutlineBuilder } ;
5
- use rustybuzz:: { GlyphBuffer , UnicodeBuffer } ;
6
+ use rustybuzz:: { GlyphBuffer , GlyphPosition , UnicodeBuffer } ;
6
7
7
8
struct Builder {
8
9
current_subpath : Subpath < PointId > ,
@@ -77,13 +78,24 @@ fn wrap_word(max_width: Option<f64>, glyph_buffer: &GlyphBuffer, font_size: f64,
77
78
false
78
79
}
79
80
81
+ #[ cfg_attr( feature = "serde" , derive( serde:: Serialize , serde:: Deserialize ) ) ]
82
+ #[ derive( Default , PartialEq , Eq , Clone , Copy , Debug , Hash , specta:: Type , node_macro:: ChoiceType , DynAny ) ]
83
+ #[ widget( Radio ) ]
84
+ pub enum TextAlignment {
85
+ #[ default]
86
+ Left ,
87
+ Center ,
88
+ Right ,
89
+ }
90
+
80
91
#[ derive( PartialEq , Clone , Copy , Debug , serde:: Serialize , serde:: Deserialize ) ]
81
92
pub struct TypesettingConfig {
82
93
pub font_size : f64 ,
83
94
pub line_height_ratio : f64 ,
84
95
pub character_spacing : f64 ,
85
96
pub max_width : Option < f64 > ,
86
97
pub max_height : Option < f64 > ,
98
+ pub text_alignment : TextAlignment ,
87
99
}
88
100
89
101
impl Default for TypesettingConfig {
@@ -94,64 +106,121 @@ impl Default for TypesettingConfig {
94
106
character_spacing : 1. ,
95
107
max_width : None ,
96
108
max_height : None ,
109
+ text_alignment : TextAlignment :: Center ,
97
110
}
98
111
}
99
112
}
100
113
101
- pub fn to_path ( str : & str , buzz_face : Option < rustybuzz:: Face > , typesetting : TypesettingConfig ) -> Vec < Subpath < PointId > > {
102
- let Some ( buzz_face) = buzz_face else { return vec ! [ ] } ;
103
- let space_glyph = buzz_face. glyph_index ( ' ' ) ;
114
+ #[ derive( Default , Debug ) ]
115
+ struct GlyphRow {
116
+ glyphs : Vec < ( GlyphId , GlyphPosition ) > ,
117
+ width : f64 ,
118
+ }
104
119
105
- let ( scale, line_height, mut buffer) = font_properties ( & buzz_face, typesetting. font_size , typesetting. line_height_ratio ) ;
120
+ impl GlyphRow {
121
+ fn append ( & mut self , glyph_id : GlyphId , glyph_position : GlyphPosition , advance : f64 ) {
122
+ self . width += advance;
123
+ self . glyphs . push ( ( glyph_id, glyph_position) ) ;
124
+ }
106
125
107
- let mut builder = Builder {
108
- current_subpath : Subpath :: new ( Vec :: new ( ) , false ) ,
109
- other_subpaths : Vec :: new ( ) ,
110
- text_cursor : DVec2 :: ZERO ,
111
- offset : DVec2 :: ZERO ,
112
- ascender : ( buzz_face . ascender ( ) as f64 / buzz_face . height ( ) as f64 ) * typesetting . font_size / scale ,
113
- scale ,
114
- id : PointId :: ZERO ,
115
- } ;
126
+ fn pop_trailing_space ( & mut self , space_glyph : Option < GlyphId > , scale : f64 , spacing : f64 ) {
127
+ if let Some ( ( last_glyph_id , _ ) ) = self . glyphs . last ( ) {
128
+ if space_glyph == Some ( * last_glyph_id ) {
129
+ self . width -= self . glyphs . last ( ) . map_or ( 0. , | ( _ , pos ) | pos . x_advance as f64 * scale * spacing ) ;
130
+ self . glyphs . pop ( ) ;
131
+ }
132
+ }
133
+ }
134
+ }
116
135
117
- for line in str. split ( '\n' ) {
118
- for ( index, word) in SplitWordsIncludingSpaces :: new ( line) . enumerate ( ) {
136
+ fn precompute_shapes ( input : & str , buzz_face : & rustybuzz:: Face , typesetting : TypesettingConfig ) -> Vec < GlyphRow > {
137
+ let space_glyph = buzz_face. glyph_index ( ' ' ) ;
138
+ let mut shaped_lines = Vec :: new ( ) ;
139
+ let ( scale, line_height, mut buffer) = font_properties ( buzz_face, typesetting. font_size , typesetting. line_height_ratio ) ;
140
+ for line in input. lines ( ) {
141
+ let mut current_line = GlyphRow :: default ( ) ;
142
+ for word in SplitWordsIncludingSpaces :: new ( line) {
119
143
push_str ( & mut buffer, word) ;
120
- let glyph_buffer = rustybuzz:: shape ( & buzz_face, & [ ] , buffer) ;
144
+ let glyph_buffer = rustybuzz:: shape ( buzz_face, & [ ] , buffer) ;
121
145
122
146
// Don't wrap the first word
123
- if index != 0 && wrap_word ( typesetting. max_width , & glyph_buffer, scale, typesetting. character_spacing , builder. text_cursor . x , space_glyph) {
124
- builder. text_cursor = DVec2 :: new ( 0. , builder. text_cursor . y + line_height) ;
147
+ if !current_line. glyphs . is_empty ( ) && wrap_word ( typesetting. max_width , & glyph_buffer, scale, typesetting. character_spacing , current_line. width , space_glyph) {
148
+ // use a trailing space only for wrapping and do no account for len
149
+ current_line. pop_trailing_space ( space_glyph, scale, typesetting. character_spacing ) ;
150
+ shaped_lines. push ( core:: mem:: take ( & mut current_line) ) ;
125
151
}
126
152
127
153
for ( glyph_position, glyph_info) in glyph_buffer. glyph_positions ( ) . iter ( ) . zip ( glyph_buffer. glyph_infos ( ) ) {
154
+ let advance = glyph_position. x_advance as f64 * scale * typesetting. character_spacing ;
128
155
let glyph_id = GlyphId ( glyph_info. glyph_id as u16 ) ;
129
- if let Some ( max_width) = typesetting. max_width {
130
- if space_glyph != Some ( glyph_id) && builder. text_cursor . x + ( glyph_position. x_advance as f64 * builder. scale * typesetting. character_spacing ) >= max_width {
131
- builder. text_cursor = DVec2 :: new ( 0. , builder. text_cursor . y + line_height) ;
132
- }
156
+ if typesetting
157
+ . max_width
158
+ . is_some_and ( |max_width| space_glyph != Some ( glyph_id) && current_line. width + advance >= max_width)
159
+ {
160
+ shaped_lines. push ( core:: mem:: take ( & mut current_line) ) ;
133
161
}
162
+ current_line. append ( glyph_id, * glyph_position, advance) ;
163
+
134
164
// Clip when the height is exceeded
135
- if typesetting. max_height . is_some_and ( |max_height| builder . text_cursor . y > max_height - line_height) {
136
- return builder . other_subpaths ;
165
+ if typesetting. max_height . is_some_and ( |max_height| shaped_lines . len ( ) as f64 * line_height > max_height - line_height) {
166
+ return shaped_lines ;
137
167
}
168
+ }
169
+ buffer = glyph_buffer. clear ( ) ;
170
+ }
171
+ // use a trailing space only for wrapping and do no account for len
172
+ current_line. pop_trailing_space ( space_glyph, scale, typesetting. character_spacing ) ;
173
+ shaped_lines. push ( core:: mem:: take ( & mut current_line) ) ;
174
+ }
175
+ shaped_lines
176
+ }
138
177
139
- builder . offset = DVec2 :: new ( glyph_position . x_offset as f64 , glyph_position . y_offset as f64 ) * builder . scale ;
140
- buzz_face . outline_glyph ( glyph_id , & mut builder ) ;
141
- if !builder . current_subpath . is_empty ( ) {
142
- builder . other_subpaths . push ( core :: mem :: replace ( & mut builder . current_subpath , Subpath :: new ( Vec :: new ( ) , false ) ) ) ;
143
- }
178
+ fn render_shapes ( shaped_lines : Vec < GlyphRow > , typesetting : TypesettingConfig , buzz_face : & rustybuzz :: Face ) -> Vec < Subpath < PointId > > {
179
+ let overall_width = typesetting
180
+ . max_width
181
+ . unwrap_or_else ( || shaped_lines . iter ( ) . max_by_key ( |line| line . width as u64 ) . map_or ( 0. , |x| x . width ) ) ;
182
+ let ( scale , line_height , _ ) = font_properties ( buzz_face , typesetting . font_size , typesetting . line_height_ratio ) ;
144
183
145
- builder. text_cursor += DVec2 :: new ( glyph_position. x_advance as f64 * typesetting. character_spacing , glyph_position. y_advance as f64 ) * builder. scale ;
184
+ let mut builder = Builder {
185
+ current_subpath : Subpath :: new ( Vec :: new ( ) , false ) ,
186
+ other_subpaths : Vec :: new ( ) ,
187
+ text_cursor : DVec2 :: ZERO ,
188
+ offset : DVec2 :: ZERO ,
189
+ ascender : ( buzz_face. ascender ( ) as f64 / buzz_face. height ( ) as f64 ) * typesetting. font_size / scale,
190
+ scale,
191
+ id : PointId :: ZERO ,
192
+ } ;
193
+ for ( line_number, glyph_line) in shaped_lines. into_iter ( ) . enumerate ( ) {
194
+ let x_offset = alignment_offset ( typesetting. text_alignment , glyph_line. width , overall_width) ;
195
+ builder. text_cursor = DVec2 :: new ( x_offset, line_number as f64 * line_height) ;
196
+ for ( glyph_id, glyph_position) in glyph_line. glyphs {
197
+ builder. offset = DVec2 :: new ( glyph_position. x_offset as f64 , glyph_position. y_offset as f64 ) * builder. scale ;
198
+ buzz_face. outline_glyph ( glyph_id, & mut builder) ;
199
+
200
+ if !builder. current_subpath . is_empty ( ) {
201
+ builder. other_subpaths . push ( core:: mem:: replace ( & mut builder. current_subpath , Subpath :: new ( Vec :: new ( ) , false ) ) ) ;
146
202
}
147
203
148
- buffer = glyph_buffer . clear ( ) ;
204
+ builder . text_cursor += DVec2 :: new ( glyph_position . x_advance as f64 * typesetting . character_spacing , glyph_position . y_advance as f64 ) * builder . scale ;
149
205
}
206
+ }
207
+ builder. other_subpaths
208
+ }
150
209
151
- builder. text_cursor = DVec2 :: new ( 0. , builder. text_cursor . y + line_height) ;
210
+ fn alignment_offset ( align : TextAlignment , line_width : f64 , total_width : f64 ) -> f64 {
211
+ match align {
212
+ TextAlignment :: Left => 0. ,
213
+ TextAlignment :: Center => ( total_width - line_width) / 2. ,
214
+ TextAlignment :: Right => total_width - line_width,
152
215
}
216
+ . max ( 0. )
217
+ }
153
218
154
- builder. other_subpaths
219
+ pub fn to_path ( str : & str , buzz_face : Option < rustybuzz:: Face > , typesetting : TypesettingConfig ) -> Vec < Subpath < PointId > > {
220
+ let Some ( buzz_face) = buzz_face else { return vec ! [ ] } ;
221
+
222
+ let all_shapes = precompute_shapes ( str, & buzz_face, typesetting) ;
223
+ render_shapes ( all_shapes, typesetting, & buzz_face)
155
224
}
156
225
157
226
pub fn bounding_box ( str : & str , buzz_face : Option < & rustybuzz:: Face > , typesetting : TypesettingConfig , for_clipping_test : bool ) -> DVec2 {
@@ -247,3 +316,77 @@ fn split_words_including_spaces() {
247
316
assert_eq ! ( split_words. next( ) , Some ( "." ) ) ;
248
317
assert_eq ! ( split_words. next( ) , None ) ;
249
318
}
319
+ /*
320
+ #[cfg(test)]
321
+ fn test_font_face() -> rustybuzz::Face<'static> {
322
+ let data = include_bytes!("./Savate-VariableFont_wght.ttf") as &[u8];
323
+ rustybuzz::Face::from_slice(data, 0).expect("Failed to load test font")
324
+ }
325
+
326
+ #[test]
327
+ fn test_empty_string_returns_no_paths() {
328
+ let buzz_face = Some(test_font_face());
329
+ let config = TypesettingConfig::default();
330
+
331
+ let result = to_path("", buzz_face, config);
332
+ assert!(result.is_empty());
333
+ }
334
+
335
+ #[test]
336
+ fn test_simple_text_renders_some_paths() {
337
+ let buzz_face = Some(test_font_face());
338
+ let config = TypesettingConfig::default();
339
+
340
+ let result = to_path("Hello", buzz_face, config);
341
+ eprintln!("Rendered paths: {:?}", result);
342
+ assert!(!result.is_empty(), "Expected paths to be rendered for non-empty text");
343
+ }
344
+
345
+ #[test]
346
+ fn test_line_wrapping_on_max_width() {
347
+ let buzz_face = Some(test_font_face());
348
+ let config = TypesettingConfig {
349
+ max_width: Some(50.0),
350
+ ..Default::default()
351
+ };
352
+
353
+ let result = to_path("This should wrap", buzz_face, config);
354
+ // Count how many unique y positions exist in the path — indicating line breaks
355
+ let line_count = result
356
+ .iter()
357
+ .map(|subpath| subpath.manipulator_groups().first().map(|p| p.anchor.y as u32))
358
+ .flatten()
359
+ .collect::<std::collections::HashSet<_>>()
360
+ .len();
361
+ assert!(line_count > 1, "Expected line wrapping, but only one line was rendered");
362
+ }
363
+
364
+ #[test]
365
+ fn test_alignment_offsets() {
366
+ let left = alignment_offset(TextAlignment::Left, 100.0, 500.0);
367
+ let center = alignment_offset(TextAlignment::Center, 100.0, 500.0);
368
+ let right = alignment_offset(TextAlignment::Right, 100.0, 500.0);
369
+
370
+ assert_eq!(left, 0.0);
371
+ assert_eq!(center, 200.0);
372
+ assert_eq!(right, 400.0);
373
+ }
374
+
375
+ #[test]
376
+ fn test_height_clipping() {
377
+ let buzz_face = Some(test_font_face());
378
+ let config = TypesettingConfig {
379
+ max_height: Some(12.0),
380
+ ..Default::default()
381
+ };
382
+
383
+ let result = to_path("Line1\nLine2\nLine3\nLine4", buzz_face, config);
384
+ let unique_lines = result
385
+ .iter()
386
+ .map(|s| s.manipulator_groups().first().map(|p| p.anchor.y as u32))
387
+ .flatten()
388
+ .collect::<std::collections::HashSet<_>>();
389
+ //.len();
390
+ assert!(unique_lines.len() <= 2, "Too many lines rendered, max_height not respected");
391
+ }
392
+ */
0 commit comments