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/// Why a hyphenation dictionary could not be registered.
16#[cfg(feature = "text-hyphenation")]
17#[derive(thiserror::Error, Debug)]
18pub enum HyphenationDictionaryError {
19    /// The locale tag does not name a language that has a hyphenation dictionary.
20    #[error("Unsupported hyphenation locale: {0}")]
21    UnsupportedLocale(String),
22    /// The dictionary for `locale` could not be read or parsed.
23    #[error("Failed to load hyphenation dictionary for {locale}: {message}")]
24    LoadFailed {
25        /// The locale tag as it was given.
26        locale: String,
27        /// The error text of the dictionary loader.
28        message: String,
29    },
30    /// The dictionary store is unusable because a thread panicked while writing to it.
31    #[error("Hyphenation dictionary cache is unavailable")]
32    CacheUnavailable,
33}
34
35/// Hyphenation dictionaries by language. Dictionaries that the app registers are used first, and
36/// a dictionary embedded in the build is the fallback when `text-hyphenation-embedded` is on.
37#[cfg(feature = "text-hyphenation")]
38pub struct HyphenationDictionaryStore {
39    dictionaries: RwLock<HashMap<Language, Standard>>,
40}
41
42#[cfg(feature = "text-hyphenation")]
43impl Default for HyphenationDictionaryStore {
44    fn default() -> Self {
45        Self::new()
46    }
47}
48
49#[cfg(feature = "text-hyphenation")]
50impl HyphenationDictionaryStore {
51    /// Creates a store with no registered dictionaries.
52    pub fn new() -> Self {
53        Self {
54            dictionaries: RwLock::new(HashMap::new()),
55        }
56    }
57
58    /// Registers the dictionary for `locale` read from the file at `path`, replacing an earlier
59    /// one.
60    pub fn register_dictionary_path(
61        &self,
62        locale: &str,
63        path: impl AsRef<Path>,
64    ) -> Result<(), HyphenationDictionaryError> {
65        let language = resolve_language_tag(locale)
66            .ok_or_else(|| HyphenationDictionaryError::UnsupportedLocale(locale.to_string()))?;
67        let dictionary = Standard::from_path(language, path).map_err(|err| {
68            HyphenationDictionaryError::LoadFailed {
69                locale: locale.to_string(),
70                message: err.to_string(),
71            }
72        })?;
73        self.store_dictionary(language, dictionary)
74    }
75
76    /// Registers the dictionary for `locale` read from `reader`, replacing an earlier one.
77    pub fn register_dictionary_reader(
78        &self,
79        locale: &str,
80        reader: &mut impl std::io::Read,
81    ) -> Result<(), HyphenationDictionaryError> {
82        let language = resolve_language_tag(locale)
83            .ok_or_else(|| HyphenationDictionaryError::UnsupportedLocale(locale.to_string()))?;
84        let dictionary = Standard::from_reader(language, reader).map_err(|err| {
85            HyphenationDictionaryError::LoadFailed {
86                locale: locale.to_string(),
87                message: err.to_string(),
88            }
89        })?;
90        self.store_dictionary(language, dictionary)
91    }
92
93    fn store_dictionary(
94        &self,
95        language: Language,
96        dictionary: Standard,
97    ) -> Result<(), HyphenationDictionaryError> {
98        let mut write_guard = self
99            .dictionaries
100            .write()
101            .map_err(|_| HyphenationDictionaryError::CacheUnavailable)?;
102        write_guard.insert(language, dictionary);
103        Ok(())
104    }
105
106    fn get_dictionary(&self, language: Language) -> Option<Standard> {
107        if let Ok(read_guard) = self.dictionaries.read()
108            && let Some(dict) = read_guard.get(&language)
109        {
110            return Some(dict.clone());
111        }
112
113        #[cfg(feature = "text-hyphenation-embedded")]
114        {
115            if let Ok(dict) = Standard::from_embedded(language) {
116                let _ = self.store_dictionary(language, dict.clone());
117                return Some(dict);
118            }
119        }
120
121        None
122    }
123
124    /// Chooses where to hyphenate the word that contains the break at character
125    /// `measured_break_char` of `line`. `segment_start_char` is the character index where the
126    /// current line segment starts, and the language comes from the first locale of `style`,
127    /// English when it has none. Returns the character index of the break, at least two
128    /// characters after the segment start and not after `measured_break_char`, or `None` when
129    /// the break is not inside a word or no dictionary is available.
130    pub fn choose_auto_hyphen_break(
131        &self,
132        line: &str,
133        style: &TextStyle,
134        segment_start_char: usize,
135        measured_break_char: usize,
136    ) -> Option<usize> {
137        if line.is_empty() || measured_break_char <= segment_start_char {
138            return None;
139        }
140
141        let language = resolve_hyphenation_language(style)?;
142
143        let dictionary = self.get_dictionary(language)?;
144        let boundaries = char_boundaries(line);
145        let char_count = boundaries.len().saturating_sub(1);
146
147        if measured_break_char == 0 || measured_break_char >= char_count {
148            return None;
149        }
150        if !is_break_inside_word(line, &boundaries, measured_break_char) {
151            return None;
152        }
153
154        let (word_start, word_end) = word_bounds(line, &boundaries, measured_break_char);
155        let word = &line[boundaries[word_start]..boundaries[word_end]];
156        if word.is_empty() {
157            return None;
158        }
159
160        let max_local_break = measured_break_char.saturating_sub(word_start);
161        let min_local_break = segment_start_char
162            .saturating_sub(word_start)
163            .saturating_add(MIN_SEGMENT_CHARS);
164
165        if min_local_break > max_local_break {
166            return None;
167        }
168
169        let hyphenated = dictionary.hyphenate(word);
170        for break_byte in hyphenated.breaks.into_iter().rev() {
171            if !word.is_char_boundary(break_byte) {
172                continue;
173            }
174            let local_break_chars = word[..break_byte].chars().count();
175            if local_break_chars < min_local_break || local_break_chars > max_local_break {
176                continue;
177            }
178            return Some(word_start + local_break_chars);
179        }
180
181        None
182    }
183}
184
185/// Stands in for the dictionary store when the `text-hyphenation` feature is off. It never finds
186/// a break.
187#[cfg(not(feature = "text-hyphenation"))]
188#[derive(Default)]
189pub struct HyphenationDictionaryStore;
190
191#[cfg(not(feature = "text-hyphenation"))]
192impl HyphenationDictionaryStore {
193    /// Creates the store.
194    pub fn new() -> Self {
195        Self
196    }
197
198    /// Always returns `None`, because hyphenation is not built in.
199    pub fn choose_auto_hyphen_break(
200        &self,
201        line: &str,
202        _style: &TextStyle,
203        segment_start_char: usize,
204        measured_break_char: usize,
205    ) -> Option<usize> {
206        let _ = (self, line, segment_start_char, measured_break_char);
207        None
208    }
209}
210
211/// Chooses a hyphenation break with a store that holds no registered dictionaries, so only a
212/// dictionary embedded in the build can be used. See
213/// `HyphenationDictionaryStore::choose_auto_hyphen_break`.
214pub fn choose_auto_hyphen_break(
215    line: &str,
216    style: &TextStyle,
217    segment_start_char: usize,
218    measured_break_char: usize,
219) -> Option<usize> {
220    HyphenationDictionaryStore::new().choose_auto_hyphen_break(
221        line,
222        style,
223        segment_start_char,
224        measured_break_char,
225    )
226}
227
228#[cfg(feature = "text-hyphenation")]
229fn resolve_hyphenation_language(style: &TextStyle) -> Option<Language> {
230    let Some(locale_list) = style.span_style.locale_list.as_ref() else {
231        return Some(Language::EnglishUS);
232    };
233    if locale_list.is_empty() {
234        return Some(Language::EnglishUS);
235    }
236
237    let primary_locale = locale_list.locales().first()?;
238    resolve_language_tag(primary_locale)
239}
240
241#[cfg(feature = "text-hyphenation")]
242fn resolve_language_tag(locale: &str) -> Option<Language> {
243    if locale.trim().is_empty() {
244        return Some(Language::EnglishUS);
245    }
246
247    let normalized = locale.trim().replace('_', "-").to_ascii_lowercase();
248
249    if normalized.starts_with("en-gb") {
250        return Some(Language::EnglishGB);
251    }
252    if normalized.starts_with("en") || normalized == "und" {
253        return Some(Language::EnglishUS);
254    }
255    if normalized.starts_with("fr") {
256        return Some(Language::French);
257    }
258    if normalized.starts_with("de") {
259        return Some(Language::German1996);
260    }
261    if normalized.starts_with("es") {
262        return Some(Language::Spanish);
263    }
264    if normalized.starts_with("it") {
265        return Some(Language::Italian);
266    }
267    if normalized.starts_with("ru") {
268        return Some(Language::Russian);
269    }
270    if normalized.starts_with("pt") {
271        return Some(Language::Portuguese);
272    }
273    if normalized.starts_with("nl") {
274        return Some(Language::Dutch);
275    }
276    if normalized.starts_with("pl") {
277        return Some(Language::Polish);
278    }
279    if normalized.starts_with("sv") {
280        return Some(Language::Swedish);
281    }
282    if normalized.starts_with("da") {
283        return Some(Language::Danish);
284    }
285    if normalized.starts_with("cs") {
286        return Some(Language::Czech);
287    }
288    if normalized.starts_with("sk") {
289        return Some(Language::Slovak);
290    }
291    if normalized.starts_with("uk") {
292        return Some(Language::Ukrainian);
293    }
294
295    None
296}
297
298#[cfg(feature = "text-hyphenation")]
299fn char_boundaries(text: &str) -> Vec<usize> {
300    let mut out = Vec::with_capacity(text.chars().count() + 1);
301    out.push(0);
302    for (idx, _) in text.char_indices() {
303        if idx != 0 {
304            out.push(idx);
305        }
306    }
307    out.push(text.len());
308    out
309}
310
311#[cfg(feature = "text-hyphenation")]
312fn is_break_inside_word(line: &str, boundaries: &[usize], break_idx: usize) -> bool {
313    if break_idx == 0 || break_idx >= boundaries.len() - 1 {
314        return false;
315    }
316    let prev = &line[boundaries[break_idx - 1]..boundaries[break_idx]];
317    let next = &line[boundaries[break_idx]..boundaries[break_idx + 1]];
318    !prev.chars().all(char::is_whitespace) && !next.chars().all(char::is_whitespace)
319}
320
321#[cfg(feature = "text-hyphenation")]
322fn word_bounds(line: &str, boundaries: &[usize], anchor: usize) -> (usize, usize) {
323    let mut start = anchor;
324    while start > 0 {
325        let prev = &line[boundaries[start - 1]..boundaries[start]];
326        if prev.chars().all(char::is_whitespace) {
327            break;
328        }
329        start -= 1;
330    }
331
332    let mut end = anchor;
333    while end < boundaries.len() - 1 {
334        let current = &line[boundaries[end]..boundaries[end + 1]];
335        if current.chars().all(char::is_whitespace) {
336            break;
337        }
338        end += 1;
339    }
340    (start, end)
341}
342
343#[cfg(all(test, not(feature = "text-hyphenation")))]
344#[path = "tests/text_hyphenation_disabled_tests.rs"]
345mod disabled_tests;
346
347#[cfg(all(test, feature = "text-hyphenation-embedded"))]
348#[path = "tests/text_hyphenation_tests.rs"]
349mod tests;