cranpose_render_common/
text_hyphenation.rs1#[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")]
17#[derive(thiserror::Error, Debug)]
18pub enum HyphenationDictionaryError {
19 #[error("Unsupported hyphenation locale: {0}")]
21 UnsupportedLocale(String),
22 #[error("Failed to load hyphenation dictionary for {locale}: {message}")]
24 LoadFailed {
25 locale: String,
27 message: String,
29 },
30 #[error("Hyphenation dictionary cache is unavailable")]
32 CacheUnavailable,
33}
34
35#[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 pub fn new() -> Self {
53 Self {
54 dictionaries: RwLock::new(HashMap::new()),
55 }
56 }
57
58 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 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 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#[cfg(not(feature = "text-hyphenation"))]
188#[derive(Default)]
189pub struct HyphenationDictionaryStore;
190
191#[cfg(not(feature = "text-hyphenation"))]
192impl HyphenationDictionaryStore {
193 pub fn new() -> Self {
195 Self
196 }
197
198 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
211pub 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;