1#[cfg(feature = "text-hyphenation")]
2use std::collections::HashMap;
3#[cfg(feature = "text-hyphenation")]
4use std::path::Path;
5#[cfg(feature = "text-hyphenation")]
6use std::sync::RwLock;
7
8use cranpose_ui::text::TextStyle;
9#[cfg(feature = "text-hyphenation")]
10use hyphenation::{Hyphenator, Language, Load, Standard};
11
12#[cfg(feature = "text-hyphenation")]
13const MIN_SEGMENT_CHARS: usize = 2;
14
15#[cfg(feature = "text-hyphenation")]
16#[derive(thiserror::Error, Debug)]
17pub enum HyphenationDictionaryError {
18 #[error("Unsupported hyphenation locale: {0}")]
19 UnsupportedLocale(String),
20 #[error("Failed to load hyphenation dictionary for {locale}: {message}")]
21 LoadFailed { locale: String, message: String },
22 #[error("Hyphenation dictionary cache is unavailable")]
23 CacheUnavailable,
24}
25
26#[cfg(feature = "text-hyphenation")]
27pub struct HyphenationDictionaryStore {
28 dictionaries: RwLock<HashMap<Language, Standard>>,
29}
30
31#[cfg(feature = "text-hyphenation")]
32impl Default for HyphenationDictionaryStore {
33 fn default() -> Self {
34 Self::new()
35 }
36}
37
38#[cfg(feature = "text-hyphenation")]
39impl HyphenationDictionaryStore {
40 pub fn new() -> Self {
41 Self {
42 dictionaries: RwLock::new(HashMap::new()),
43 }
44 }
45
46 pub fn register_dictionary_path(
47 &self,
48 locale: &str,
49 path: impl AsRef<Path>,
50 ) -> Result<(), HyphenationDictionaryError> {
51 let language = resolve_language_tag(locale)
52 .ok_or_else(|| HyphenationDictionaryError::UnsupportedLocale(locale.to_string()))?;
53 let dictionary = Standard::from_path(language, path).map_err(|err| {
54 HyphenationDictionaryError::LoadFailed {
55 locale: locale.to_string(),
56 message: err.to_string(),
57 }
58 })?;
59 self.store_dictionary(language, dictionary)
60 }
61
62 pub fn register_dictionary_reader(
63 &self,
64 locale: &str,
65 reader: &mut impl std::io::Read,
66 ) -> Result<(), HyphenationDictionaryError> {
67 let language = resolve_language_tag(locale)
68 .ok_or_else(|| HyphenationDictionaryError::UnsupportedLocale(locale.to_string()))?;
69 let dictionary = Standard::from_reader(language, reader).map_err(|err| {
70 HyphenationDictionaryError::LoadFailed {
71 locale: locale.to_string(),
72 message: err.to_string(),
73 }
74 })?;
75 self.store_dictionary(language, dictionary)
76 }
77
78 fn store_dictionary(
79 &self,
80 language: Language,
81 dictionary: Standard,
82 ) -> Result<(), HyphenationDictionaryError> {
83 let mut write_guard = self
84 .dictionaries
85 .write()
86 .map_err(|_| HyphenationDictionaryError::CacheUnavailable)?;
87 write_guard.insert(language, dictionary);
88 Ok(())
89 }
90
91 fn get_dictionary(&self, language: Language) -> Option<Standard> {
92 if let Ok(read_guard) = self.dictionaries.read()
93 && let Some(dict) = read_guard.get(&language)
94 {
95 return Some(dict.clone());
96 }
97
98 #[cfg(feature = "text-hyphenation-embedded")]
99 {
100 if let Ok(dict) = Standard::from_embedded(language) {
101 let _ = self.store_dictionary(language, dict.clone());
102 return Some(dict);
103 }
104 }
105
106 None
107 }
108
109 pub fn choose_auto_hyphen_break(
110 &self,
111 line: &str,
112 style: &TextStyle,
113 segment_start_char: usize,
114 measured_break_char: usize,
115 ) -> Option<usize> {
116 if line.is_empty() || measured_break_char <= segment_start_char {
117 return None;
118 }
119
120 let language = resolve_hyphenation_language(style)?;
121
122 let dictionary = self.get_dictionary(language)?;
123 let boundaries = char_boundaries(line);
124 let char_count = boundaries.len().saturating_sub(1);
125
126 if measured_break_char == 0 || measured_break_char >= char_count {
127 return None;
128 }
129 if !is_break_inside_word(line, &boundaries, measured_break_char) {
130 return None;
131 }
132
133 let (word_start, word_end) = word_bounds(line, &boundaries, measured_break_char);
134 let word = &line[boundaries[word_start]..boundaries[word_end]];
135 if word.is_empty() {
136 return None;
137 }
138
139 let max_local_break = measured_break_char.saturating_sub(word_start);
140 let min_local_break = segment_start_char
141 .saturating_sub(word_start)
142 .saturating_add(MIN_SEGMENT_CHARS);
143
144 if min_local_break > max_local_break {
145 return None;
146 }
147
148 let hyphenated = dictionary.hyphenate(word);
149 for break_byte in hyphenated.breaks.into_iter().rev() {
150 if !word.is_char_boundary(break_byte) {
151 continue;
152 }
153 let local_break_chars = word[..break_byte].chars().count();
154 if local_break_chars < min_local_break || local_break_chars > max_local_break {
155 continue;
156 }
157 return Some(word_start + local_break_chars);
158 }
159
160 None
161 }
162}
163
164#[cfg(not(feature = "text-hyphenation"))]
165#[derive(Default)]
166pub struct HyphenationDictionaryStore;
167
168#[cfg(not(feature = "text-hyphenation"))]
169impl HyphenationDictionaryStore {
170 pub fn new() -> Self {
171 Self
172 }
173
174 pub fn choose_auto_hyphen_break(
175 &self,
176 line: &str,
177 _style: &TextStyle,
178 segment_start_char: usize,
179 measured_break_char: usize,
180 ) -> Option<usize> {
181 let _ = (self, line, segment_start_char, measured_break_char);
182 None
183 }
184}
185
186pub fn choose_auto_hyphen_break(
187 line: &str,
188 style: &TextStyle,
189 segment_start_char: usize,
190 measured_break_char: usize,
191) -> Option<usize> {
192 HyphenationDictionaryStore::new().choose_auto_hyphen_break(
193 line,
194 style,
195 segment_start_char,
196 measured_break_char,
197 )
198}
199
200#[cfg(feature = "text-hyphenation")]
201fn resolve_hyphenation_language(style: &TextStyle) -> Option<Language> {
202 let Some(locale_list) = style.span_style.locale_list.as_ref() else {
203 return Some(Language::EnglishUS);
204 };
205 if locale_list.is_empty() {
206 return Some(Language::EnglishUS);
207 }
208
209 let primary_locale = locale_list.locales().first()?;
210 resolve_language_tag(primary_locale)
211}
212
213#[cfg(feature = "text-hyphenation")]
214fn resolve_language_tag(locale: &str) -> Option<Language> {
215 if locale.trim().is_empty() {
216 return Some(Language::EnglishUS);
217 }
218
219 let normalized = locale.trim().replace('_', "-").to_ascii_lowercase();
220
221 if normalized.starts_with("en-gb") {
222 return Some(Language::EnglishGB);
223 }
224 if normalized.starts_with("en") || normalized == "und" {
225 return Some(Language::EnglishUS);
226 }
227 if normalized.starts_with("fr") {
228 return Some(Language::French);
229 }
230 if normalized.starts_with("de") {
231 return Some(Language::German1996);
232 }
233 if normalized.starts_with("es") {
234 return Some(Language::Spanish);
235 }
236 if normalized.starts_with("it") {
237 return Some(Language::Italian);
238 }
239 if normalized.starts_with("ru") {
240 return Some(Language::Russian);
241 }
242 if normalized.starts_with("pt") {
243 return Some(Language::Portuguese);
244 }
245 if normalized.starts_with("nl") {
246 return Some(Language::Dutch);
247 }
248 if normalized.starts_with("pl") {
249 return Some(Language::Polish);
250 }
251 if normalized.starts_with("sv") {
252 return Some(Language::Swedish);
253 }
254 if normalized.starts_with("da") {
255 return Some(Language::Danish);
256 }
257 if normalized.starts_with("cs") {
258 return Some(Language::Czech);
259 }
260 if normalized.starts_with("sk") {
261 return Some(Language::Slovak);
262 }
263 if normalized.starts_with("uk") {
264 return Some(Language::Ukrainian);
265 }
266
267 None
268}
269
270#[cfg(feature = "text-hyphenation")]
271fn char_boundaries(text: &str) -> Vec<usize> {
272 let mut out = Vec::with_capacity(text.chars().count() + 1);
273 out.push(0);
274 for (idx, _) in text.char_indices() {
275 if idx != 0 {
276 out.push(idx);
277 }
278 }
279 out.push(text.len());
280 out
281}
282
283#[cfg(feature = "text-hyphenation")]
284fn is_break_inside_word(line: &str, boundaries: &[usize], break_idx: usize) -> bool {
285 if break_idx == 0 || break_idx >= boundaries.len() - 1 {
286 return false;
287 }
288 let prev = &line[boundaries[break_idx - 1]..boundaries[break_idx]];
289 let next = &line[boundaries[break_idx]..boundaries[break_idx + 1]];
290 !prev.chars().all(char::is_whitespace) && !next.chars().all(char::is_whitespace)
291}
292
293#[cfg(feature = "text-hyphenation")]
294fn word_bounds(line: &str, boundaries: &[usize], anchor: usize) -> (usize, usize) {
295 let mut start = anchor;
296 while start > 0 {
297 let prev = &line[boundaries[start - 1]..boundaries[start]];
298 if prev.chars().all(char::is_whitespace) {
299 break;
300 }
301 start -= 1;
302 }
303
304 let mut end = anchor;
305 while end < boundaries.len() - 1 {
306 let current = &line[boundaries[end]..boundaries[end + 1]];
307 if current.chars().all(char::is_whitespace) {
308 break;
309 }
310 end += 1;
311 }
312 (start, end)
313}
314
315#[cfg(all(test, not(feature = "text-hyphenation")))]
316mod disabled_tests {
317 use super::*;
318
319 #[test]
320 fn auto_hyphenation_without_dictionary_feature_returns_none() {
321 let break_idx = choose_auto_hyphen_break("Transformation", &TextStyle::default(), 8, 12);
322 assert_eq!(break_idx, None);
323 }
324}
325
326#[cfg(all(test, feature = "text-hyphenation-embedded"))]
327mod tests {
328 use cranpose_ui::text::{LocaleList, SpanStyle, TextStyle};
329
330 use super::*;
331
332 fn style_with_locale(tags: &str) -> TextStyle {
333 TextStyle {
334 span_style: SpanStyle {
335 locale_list: Some(LocaleList::from_language_tags(tags)),
336 ..Default::default()
337 },
338 ..Default::default()
339 }
340 }
341
342 #[test]
343 fn dictionary_breaks_transformation_like_compose_contract() {
344 let break_idx = choose_auto_hyphen_break("Transformation", &TextStyle::default(), 8, 12);
345 assert_eq!(break_idx, Some(10));
346 }
347
348 #[test]
349 fn locale_gate_uses_french_dictionary() {
350 let break_idx = choose_auto_hyphen_break("éléphant", &style_with_locale("fr-FR"), 0, 7);
351 assert_eq!(break_idx, Some(3));
352 }
353
354 #[test]
355 fn locale_gate_uses_german_dictionary() {
356 let break_idx = choose_auto_hyphen_break(
357 "Geschwindigkeitsbegrenzung",
358 &style_with_locale("de-DE"),
359 10,
360 20,
361 );
362 assert!(break_idx.is_some());
363 }
364
365 #[test]
366 fn unknown_locale_disables_hyphenation() {
367 let break_idx =
368 choose_auto_hyphen_break("Transformation", &style_with_locale("ja-JP"), 8, 12);
369 assert_eq!(break_idx, None);
370 }
371
372 #[test]
373 fn dictionary_uses_english_locale_alias() {
374 let break_idx =
375 choose_auto_hyphen_break("Transformation", &style_with_locale("en_GB"), 8, 12);
376 assert_eq!(break_idx, Some(10));
377 }
378
379 #[test]
380 fn ignores_breaks_outside_words() {
381 let break_idx = choose_auto_hyphen_break("ab cd", &TextStyle::default(), 0, 2);
382 assert_eq!(break_idx, None);
383 }
384}