Skip to content

Commit 330f05c

Browse files
committed
Introduce text alignment
1 parent 5c8cd9a commit 330f05c

File tree

7 files changed

+204
-38
lines changed

7 files changed

+204
-38
lines changed

editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ use graph_craft::document::*;
1717
use graphene_core::raster::brush_cache::BrushCache;
1818
use graphene_core::raster::image::RasterDataTable;
1919
use graphene_core::raster::{CellularDistanceFunction, CellularReturnType, Color, DomainWarpType, FractalType, NoiseType, RedGreenBlueAlpha};
20-
use graphene_core::text::{Font, TypesettingConfig};
20+
use graphene_core::text::{Font, TextAlignment, TypesettingConfig};
2121
use graphene_core::transform::Footprint;
2222
use graphene_core::vector::VectorDataTable;
2323
use graphene_core::*;
@@ -1987,6 +1987,7 @@ fn static_nodes() -> Vec<DocumentNodeDefinition> {
19871987
NodeInput::value(TaggedValue::F64(TypesettingConfig::default().character_spacing), false),
19881988
NodeInput::value(TaggedValue::OptionalF64(TypesettingConfig::default().max_width), false),
19891989
NodeInput::value(TaggedValue::OptionalF64(TypesettingConfig::default().max_height), false),
1990+
NodeInput::value(TaggedValue::TextAlignment(TypesettingConfig::default().text_alignment), false),
19901991
],
19911992
..Default::default()
19921993
},
@@ -2040,6 +2041,7 @@ fn static_nodes() -> Vec<DocumentNodeDefinition> {
20402041
..Default::default()
20412042
}),
20422043
),
2044+
PropertiesRow::with_override("Text Alignment", "TODO", WidgetOverride::Custom("text_alignment".to_string())),
20432045
],
20442046
output_names: vec!["Vector".to_string()],
20452047
..Default::default()
@@ -3311,6 +3313,16 @@ fn static_input_properties() -> InputProperties {
33113313
)])
33123314
}),
33133315
);
3316+
map.insert(
3317+
"text_alignment".to_string(),
3318+
Box::new(|node_id, index, context| {
3319+
let (document_node, input_name, input_description) = node_properties::query_node_and_input_info(node_id, index, context)?;
3320+
let text_alignment = enum_choice::<TextAlignment>()
3321+
.for_socket(ParameterWidgetsInfo::new(document_node, node_id, index, input_name, input_description, true))
3322+
.property_row();
3323+
Ok(vec![text_alignment])
3324+
}),
3325+
);
33143326
map
33153327
}
33163328

editor/src/messages/portfolio/portfolio_message_handler.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -750,7 +750,7 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageData<'_>> for PortfolioMes
750750
}
751751

752752
// Upgrade Text node to include line height and character spacing, which were previously hardcoded to 1, from https://github.com/GraphiteEditor/Graphite/pull/2016
753-
if reference == "Text" && inputs_count != 8 {
753+
if reference == "Text" && inputs_count != 9 {
754754
let node_definition = resolve_document_node_type(reference).unwrap();
755755
let document_node = node_definition.default_node_template().document_node;
756756
document.network_interface.replace_implementation(node_id, network_path, document_node.implementation.clone());
@@ -789,6 +789,11 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageData<'_>> for PortfolioMes
789789
NodeInput::value(TaggedValue::OptionalF64(TypesettingConfig::default().max_height), false),
790790
network_path,
791791
);
792+
document.network_interface.set_input(
793+
&InputConnector::node(*node_id, 8),
794+
NodeInput::value(TaggedValue::TextAlignment(TypesettingConfig::default().text_alignment), false),
795+
network_path,
796+
);
792797
}
793798

794799
// Upgrade Sine, Cosine, and Tangent nodes to include a boolean input for whether the output should be in radians, which was previously the only option but is now not the default

editor/src/messages/tool/common_functionality/graph_modification_utils.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,13 +342,15 @@ pub fn get_text(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInter
342342
let Some(&TaggedValue::F64(character_spacing)) = inputs[5].as_value() else { return None };
343343
let Some(&TaggedValue::OptionalF64(max_width)) = inputs[6].as_value() else { return None };
344344
let Some(&TaggedValue::OptionalF64(max_height)) = inputs[7].as_value() else { return None };
345+
let Some(&TaggedValue::TextAlignment(alignment)) = inputs[8].as_value() else { return None };
345346

346347
let typesetting = TypesettingConfig {
347348
font_size,
348349
line_height_ratio,
349350
max_width,
350351
character_spacing,
351352
max_height,
353+
text_alignment: alignment,
352354
};
353355
Some((text, font, typesetting))
354356
}

editor/src/messages/tool/tool_messages/text_tool.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ use graph_craft::document::value::TaggedValue;
1818
use graph_craft::document::{NodeId, NodeInput};
1919
use graphene_core::Color;
2020
use graphene_core::renderer::Quad;
21-
use graphene_core::text::{Font, FontCache, TypesettingConfig, lines_clipping, load_face};
21+
use graphene_core::text::{Font, FontCache, TextAlignment, TypesettingConfig, lines_clipping, load_face};
2222
use graphene_core::vector::style::Fill;
2323

2424
#[derive(Default)]
@@ -784,6 +784,7 @@ impl Fsm for TextToolFsmState {
784784
max_width: constraint_size.map(|size| size.x),
785785
character_spacing: tool_options.character_spacing,
786786
max_height: constraint_size.map(|size| size.y),
787+
text_alignment: TextAlignment::default(),
787788
},
788789
font: Font::new(tool_options.font_name.clone(), tool_options.font_style.clone()),
789790
color: tool_options.fill.active_color(),

node-graph/gcore/src/text/to_path.rs

Lines changed: 177 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
use crate::vector::PointId;
22
use bezier_rs::{ManipulatorGroup, Subpath};
3+
use dyn_any::DynAny;
34
use glam::DVec2;
45
use rustybuzz::ttf_parser::{GlyphId, OutlineBuilder};
5-
use rustybuzz::{GlyphBuffer, UnicodeBuffer};
6+
use rustybuzz::{GlyphBuffer, GlyphPosition, UnicodeBuffer};
67

78
struct Builder {
89
current_subpath: Subpath<PointId>,
@@ -77,13 +78,24 @@ fn wrap_word(max_width: Option<f64>, glyph_buffer: &GlyphBuffer, font_size: f64,
7778
false
7879
}
7980

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+
8091
#[derive(PartialEq, Clone, Copy, Debug, serde::Serialize, serde::Deserialize)]
8192
pub struct TypesettingConfig {
8293
pub font_size: f64,
8394
pub line_height_ratio: f64,
8495
pub character_spacing: f64,
8596
pub max_width: Option<f64>,
8697
pub max_height: Option<f64>,
98+
pub text_alignment: TextAlignment,
8799
}
88100

89101
impl Default for TypesettingConfig {
@@ -94,64 +106,121 @@ impl Default for TypesettingConfig {
94106
character_spacing: 1.,
95107
max_width: None,
96108
max_height: None,
109+
text_alignment: TextAlignment::Center,
97110
}
98111
}
99112
}
100113

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+
}
104119

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+
}
106125

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+
}
116135

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) {
119143
push_str(&mut buffer, word);
120-
let glyph_buffer = rustybuzz::shape(&buzz_face, &[], buffer);
144+
let glyph_buffer = rustybuzz::shape(buzz_face, &[], buffer);
121145

122146
// 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));
125151
}
126152

127153
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;
128155
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));
133161
}
162+
current_line.append(glyph_id, *glyph_position, advance);
163+
134164
// 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;
137167
}
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+
}
138177

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);
144183

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)));
146202
}
147203

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;
149205
}
206+
}
207+
builder.other_subpaths
208+
}
150209

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,
152215
}
216+
.max(0.)
217+
}
153218

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)
155224
}
156225

157226
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() {
247316
assert_eq!(split_words.next(), Some("."));
248317
assert_eq!(split_words.next(), None);
249318
}
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+
*/

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ tagged_value! {
241241
ReferencePoint(graphene_core::transform::ReferencePoint),
242242
CentroidType(graphene_core::vector::misc::CentroidType),
243243
BooleanOperation(graphene_core::vector::misc::BooleanOperation),
244+
TextAlignment(graphene_core::text::TextAlignment),
244245

245246
// ImaginateCache(ImaginateCache),
246247
// ImaginateSamplingMethod(ImaginateSamplingMethod),

0 commit comments

Comments
 (0)