Skip to main content

cranpose_render_common/
text_hyphenation.rs

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}