diff --git a/Cargo.lock b/Cargo.lock index 4f0f57a3d1..66f510cdeb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1611,6 +1611,12 @@ dependencies = [ "libc", ] +[[package]] +name = "fst" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ab85b9b05e3978cc9a9cf8fea7f01b494e1a09ed3037e16ba39edc7a29eb61a" + [[package]] name = "futf" version = "0.1.5" @@ -2417,6 +2423,29 @@ dependencies = [ "windows-registry", ] +[[package]] +name = "hyphenation" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcf4dd4c44ae85155502a52c48739c8a48185d1449fff1963cffee63c28a50f0" +dependencies = [ + "bincode", + "fst", + "hyphenation_commons", + "pocket-resources", + "serde", +] + +[[package]] +name = "hyphenation_commons" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5febe7a2ade5c7d98eb8b75f946c046b335324b06a14ea0998271504134c05bf" +dependencies = [ + "fst", + "serde", +] + [[package]] name = "iai-callgrind" version = "0.16.1" @@ -4001,6 +4030,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "pocket-resources" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c135f38778ad324d9e9ee68690bac2c1a51f340fdf96ca13e2ab3914eb2e51d8" + [[package]] name = "polling" version = "3.10.0" @@ -5482,6 +5517,7 @@ dependencies = [ "core-types", "dyn-any", "glam", + "hyphenation", "log", "node-macro", "parley", diff --git a/Cargo.toml b/Cargo.toml index f7ade3d35a..829d164a1e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -152,6 +152,7 @@ vello_encoding = "0.7" resvg = "0.47" usvg = "0.47" parley = "0.6" +hyphenation = { version = "0.8.3", features = ["embed_all"] } skrifa = "0.40" polycool = "0.4" # Linebender ecosystem (END) diff --git a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs index 066076da1a..0dfb83a303 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -263,6 +263,8 @@ impl<'a> ModifyInputsContext<'a> { Some(NodeInput::value(TaggedValue::F64(typesetting.max_height.unwrap_or(100.)), false)), Some(NodeInput::value(TaggedValue::F64(typesetting.tilt), false)), Some(NodeInput::value(TaggedValue::TextAlign(typesetting.align), false)), + Some(NodeInput::value(TaggedValue::Bool(false), false)), // separate_glyph_elements + Some(NodeInput::value(TaggedValue::Bool(typesetting.hyphenate), false)), ]); let transform = resolve_network_node_type("Transform").expect("Transform node does not exist").default_node_template(); let stroke = resolve_proto_node_type(graphene_std::vector_nodes::stroke::IDENTIFIER) diff --git a/editor/src/messages/portfolio/document/overlays/utility_functions.rs b/editor/src/messages/portfolio/document/overlays/utility_functions.rs index dec2fda58e..0d1a2a9566 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_functions.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_functions.rs @@ -239,6 +239,7 @@ pub fn text_width(text: &str, font_size: f64) -> f64 { max_height: None, tilt: 0.0, align: TextAlign::Left, + hyphenate: false, }; // Load Source Sans Pro font data diff --git a/editor/src/messages/portfolio/document/overlays/utility_types_native.rs b/editor/src/messages/portfolio/document/overlays/utility_types_native.rs index 9991d39c57..47f9012ac0 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_types_native.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_types_native.rs @@ -1111,6 +1111,7 @@ impl OverlayContextInternal { max_height: None, tilt: 0., align: TextAlign::Left, // We'll handle alignment manually via pivot + hyphenate: false, }; // Load Source Sans Pro font data diff --git a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs index 51ec764b0c..1c4c4f4612 100644 --- a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs +++ b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs @@ -422,6 +422,9 @@ pub fn get_text(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInter let Some(&TaggedValue::TextAlign(align)) = inputs[graphene_std::text::text::AlignInput::INDEX].as_value() else { return None; }; + let Some(&TaggedValue::Bool(hyphenate)) = inputs[graphene_std::text::text::HyphenateInput::INDEX].as_value() else { + return None; + }; let Some(&TaggedValue::Bool(per_glyph_instances)) = inputs[graphene_std::text::text::SeparateGlyphElementsInput::INDEX].as_value() else { return None; }; @@ -434,6 +437,7 @@ pub fn get_text(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInter character_spacing, tilt, align, + hyphenate, }; Some((text, font, typesetting, per_glyph_instances)) } diff --git a/editor/src/messages/tool/tool_messages/text_tool.rs b/editor/src/messages/tool/tool_messages/text_tool.rs index c5471f0bff..9667cfc370 100644 --- a/editor/src/messages/tool/tool_messages/text_tool.rs +++ b/editor/src/messages/tool/tool_messages/text_tool.rs @@ -904,6 +904,7 @@ impl Fsm for TextToolFsmState { max_height: constraint_size.map(|size| size.y), tilt: tool_options.tilt, align: tool_options.align, + hyphenate: false, }, font: Font::new(tool_options.font.font_family.clone(), tool_options.font.font_style.clone()), color: tool_options.fill.active_color(), diff --git a/node-graph/libraries/core-types/src/text.rs b/node-graph/libraries/core-types/src/text.rs index 29917694b6..c240ae2a9b 100644 --- a/node-graph/libraries/core-types/src/text.rs +++ b/node-graph/libraries/core-types/src/text.rs @@ -43,6 +43,7 @@ pub struct TypesettingConfig { pub max_height: Option, pub tilt: f64, pub align: TextAlign, + pub hyphenate: bool, } impl Default for TypesettingConfig { @@ -55,6 +56,7 @@ impl Default for TypesettingConfig { max_height: None, tilt: 0., align: TextAlign::default(), + hyphenate: false, } } } diff --git a/node-graph/nodes/gstd/src/text.rs b/node-graph/nodes/gstd/src/text.rs index 4cb280ba30..9da6ffc3e0 100644 --- a/node-graph/nodes/gstd/src/text.rs +++ b/node-graph/nodes/gstd/src/text.rs @@ -61,6 +61,7 @@ fn text<'i: 'n>( align: TextAlign, /// Whether to split every letterform into its own vector path element. Otherwise, a single compound path is produced. separate_glyph_elements: bool, + hyphenate: bool, ) -> Table { let typesetting = TypesettingConfig { font_size: size, @@ -70,6 +71,7 @@ fn text<'i: 'n>( max_height: has_max_height.then_some(max_height), tilt, align, + hyphenate, }; to_path(&text, &font, &editor_resources.font_cache, typesetting, separate_glyph_elements) diff --git a/node-graph/nodes/text/Cargo.toml b/node-graph/nodes/text/Cargo.toml index 4537425a80..2a5100eb8c 100644 --- a/node-graph/nodes/text/Cargo.toml +++ b/node-graph/nodes/text/Cargo.toml @@ -20,6 +20,7 @@ node-macro = { workspace = true } dyn-any = { workspace = true } glam = { workspace = true } parley = { workspace = true } +hyphenation = { workspace = true } skrifa = { workspace = true } log = { workspace = true } diff --git a/node-graph/nodes/text/src/lib.rs b/node-graph/nodes/text/src/lib.rs index ca4738ffa1..224ac4a943 100644 --- a/node-graph/nodes/text/src/lib.rs +++ b/node-graph/nodes/text/src/lib.rs @@ -47,6 +47,7 @@ pub struct TypesettingConfig { pub max_height: Option, pub tilt: f64, pub align: TextAlign, + pub hyphenate: bool, } impl Default for TypesettingConfig { @@ -59,6 +60,7 @@ impl Default for TypesettingConfig { max_height: None, tilt: 0., align: TextAlign::default(), + hyphenate: false, } } } diff --git a/node-graph/nodes/text/src/text_context.rs b/node-graph/nodes/text/src/text_context.rs index 7934feb885..42c18c1c52 100644 --- a/node-graph/nodes/text/src/text_context.rs +++ b/node-graph/nodes/text/src/text_context.rs @@ -2,9 +2,11 @@ use super::{Font, FontCache, TypesettingConfig}; use core::cell::RefCell; use core_types::table::Table; use glam::DVec2; +use hyphenation::{Hyphenator, Language, Load, Standard}; use parley::fontique::{Blob, FamilyId, FontInfo}; -use parley::{AlignmentOptions, FontContext, Layout, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty}; +use parley::{AlignmentOptions, FontContext, Layout, LayoutContext, LineHeight, OverflowWrap, PositionedLayoutItem, StyleProperty}; use std::collections::HashMap; +use std::sync::OnceLock; use vector_types::Vector; use super::path_builder::PathBuilder; @@ -13,6 +15,24 @@ thread_local! { static THREAD_TEXT: RefCell = RefCell::new(TextContext::default()); } +static EN_US_DICT: OnceLock> = OnceLock::new(); + +fn get_dict_for_language(lang: Language) -> Option<&'static Standard> { + match lang { + Language::EnglishUS | Language::EnglishGB => EN_US_DICT + .get_or_init(|| { + Standard::from_embedded(Language::EnglishUS) + .map_err(|err| { + log::error!("Failed to load hyphenation dictionary: {err}"); + err + }) + .ok() + }) + .as_ref(), + _ => None, + } +} + /// Unified thread-local text processing context that combines font and layout management /// for efficient text rendering operations. #[derive(Default)] @@ -61,14 +81,25 @@ impl TextContext { } /// Create a text layout using the specified font and typesetting configuration + // TODO: Cache layout builder to avoid re-shaping text continuously. fn layout_text(&mut self, text: &str, font: &Font, font_cache: &FontCache, typesetting: TypesettingConfig) -> Option> { // Note that the actual_font may not be the desired font if that font is not yet loaded. // It is important not to cache the default font under the name of another font. let (font_data, actual_font) = self.resolve_font_data(font, font_cache)?; let (font_family, font_info) = self.get_font_info(actual_font, &font_data)?; + let injected: String; + let layout_text = if typesetting.hyphenate && typesetting.max_width.is_some() { + // TODO: pull language from typesetting config + injected = apply_hyphenation(text, Language::EnglishUS); + + &injected + } else { + text + }; + const DISPLAY_SCALE: f32 = 1.; - let mut builder = self.layout_context.ranged_builder(&mut self.font_context, text, DISPLAY_SCALE, false); + let mut builder = self.layout_context.ranged_builder(&mut self.font_context, layout_text, DISPLAY_SCALE, false); builder.push_default(StyleProperty::FontSize(typesetting.font_size as f32)); builder.push_default(StyleProperty::LetterSpacing(typesetting.character_spacing as f32)); @@ -77,8 +108,8 @@ impl TextContext { builder.push_default(StyleProperty::FontStyle(font_info.style())); builder.push_default(StyleProperty::FontWidth(font_info.width())); builder.push_default(LineHeight::FontSizeRelative(typesetting.line_height_ratio as f32)); - - let mut layout: Layout<()> = builder.build(text); + builder.push_default(StyleProperty::OverflowWrap(OverflowWrap::BreakWord)); + let mut layout = builder.build(layout_text); layout.break_all_lines(typesetting.max_width.map(|mw| mw as f32)); layout.align(typesetting.max_width.map(|max_w| max_w as f32), typesetting.align.into(), AlignmentOptions::default()); @@ -133,3 +164,38 @@ impl TextContext { max_height < bounds.y } } + +/// Apply hyphenation to the given text. +fn apply_hyphenation(text: &str, lang: Language) -> String { + let Some(dict) = get_dict_for_language(lang) else { + return text.to_string(); + }; + let mut out = String::with_capacity(text.len() + text.len() / 8); + let mut word_start: Option = None; + + fn push_hyphenated(out: &mut String, dict: &hyphenation::Standard, word: &str) { + const SOFT_HYPHEN: char = '\u{00AD}'; + let mut segs = dict.hyphenate(word).into_iter().segments().peekable(); + while let Some(seg) = segs.next() { + out.push_str(seg); + if segs.peek().is_some() { + out.push(SOFT_HYPHEN); + } + } + } + + for (i, c) in text.char_indices() { + if c.is_alphabetic() { + word_start.get_or_insert(i); + } else { + if let Some(start) = word_start.take() { + push_hyphenated(&mut out, dict, &text[start..i]); + } + out.push(c); + } + } + if let Some(start) = word_start { + push_hyphenated(&mut out, dict, &text[start..]); + } + out +}