Skip to main content

oxitext_layout/
hyphenation.rs

1//! Hyphenation support: soft-hyphen detection and automatic hyphenation.
2//!
3//! Provides two complementary break-opportunity sources:
4//!
5//! - [`soft_hyphen_breaks`] scans the input text for U+00AD SOFT HYPHEN and
6//!   returns the byte offset *after* each soft-hyphen character (i.e. the start
7//!   of the next character). This offset is where the line may be broken, which
8//!   is the same convention used by `unicode-linebreak` and the layout engine's
9//!   internal break-opportunity list.
10//!
11//! - [`automatic_hyphen_breaks`] (behind the `hyphenation` feature) uses TeX
12//!   hyphenation patterns via the [`hypher`] crate to find additional break
13//!   opportunities within words for the given language.
14
15/// Scan `text` for U+00AD SOFT HYPHEN characters and return the byte offset
16/// *after* each one.
17///
18/// The returned offsets follow the same convention used by the `unicode-linebreak`
19/// crate and the layout engine's break-opportunity list: each value is the byte
20/// index of the first character of the next segment — i.e. the position at
21/// which a line break may be inserted.  (Soft hyphens are invisible but signal
22/// a legal hyphenation point; the rendering layer is responsible for drawing a
23/// visible hyphen glyph at the break.)
24///
25/// # Examples
26/// ```
27/// use oxitext_layout::soft_hyphen_breaks;
28/// // "ma·chine" with a soft hyphen between 'a' and 'c'
29/// let breaks = soft_hyphen_breaks("ma\u{00AD}chine");
30/// assert_eq!(breaks, vec![4]); // byte 4 is 'c' (soft hyphen is 2 bytes)
31///
32/// assert!(soft_hyphen_breaks("no hyphens").is_empty());
33/// ```
34pub fn soft_hyphen_breaks(text: &str) -> Vec<usize> {
35    text.char_indices()
36        .filter_map(|(i, c)| {
37            if c == '\u{00AD}' {
38                // "after" convention: offset of the char following the soft hyphen
39                Some(i + c.len_utf8())
40            } else {
41                None
42            }
43        })
44        .collect()
45}
46
47/// Find automatic hyphenation break opportunities using TeX patterns via
48/// the [`hypher`] crate.
49///
50/// Iterates over whitespace-delimited words in `text`, hyphenates each word
51/// with `hypher::hyphenate`, and returns the byte offsets *after* each
52/// hyphenation point (relative to the start of `text`).
53///
54/// The offsets follow the same "after" convention as [`soft_hyphen_breaks`]
55/// and the `unicode-linebreak` output.
56///
57/// Only ASCII-whitespace word boundaries are considered; punctuation attached
58/// to words is included in the word passed to the hyphenator.
59///
60/// # Examples
61/// ```
62/// use oxitext_layout::hyphenation::automatic_hyphen_breaks;
63/// use hypher::Lang;
64/// let breaks = automatic_hyphen_breaks("machine", Lang::English);
65/// assert!(!breaks.is_empty());
66/// ```
67#[cfg(feature = "hyphenation")]
68pub fn automatic_hyphen_breaks(text: &str, lang: hypher::Lang) -> Vec<usize> {
69    let mut result = Vec::new();
70
71    // Iterate over words (split on ASCII whitespace), preserving byte offsets.
72    let mut remaining = text;
73    let mut base_offset = 0usize;
74
75    loop {
76        // Skip leading whitespace
77        let trimmed = remaining.trim_start_matches(|c: char| c.is_ascii_whitespace());
78        let skipped = remaining.len() - trimmed.len();
79        base_offset += skipped;
80        remaining = trimmed;
81
82        if remaining.is_empty() {
83            break;
84        }
85
86        // Find the end of the current word
87        let word_len = remaining
88            .find(|c: char| c.is_ascii_whitespace())
89            .unwrap_or(remaining.len());
90        let word = &remaining[..word_len];
91
92        // Hyphenate the word and collect break points
93        let syllables: Vec<&str> = hypher::hyphenate(word, lang).collect();
94        let mut syl_offset = 0usize;
95        for (i, syl) in syllables.iter().enumerate() {
96            syl_offset += syl.len();
97            // Break point after every syllable except the last
98            if i + 1 < syllables.len() {
99                result.push(base_offset + syl_offset);
100            }
101        }
102
103        base_offset += word_len;
104        remaining = &remaining[word_len..];
105    }
106
107    result
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn soft_hyphen_single() {
116        // "ma\u{00AD}chine": soft hyphen (2 bytes) starts at byte 2, ends at byte 4.
117        // Break opportunity is at byte 4 (the 'c').
118        assert_eq!(soft_hyphen_breaks("ma\u{00AD}chine"), vec![4]);
119    }
120
121    #[test]
122    fn soft_hyphen_none() {
123        assert!(soft_hyphen_breaks("no hyphens").is_empty());
124    }
125
126    #[test]
127    fn soft_hyphen_multiple() {
128        // "a\u{00AD}b\u{00AD}c": bytes: a(0) SHY(1,2) b(3) SHY(4,5) c(6)
129        // After-convention offsets: 3, 6
130        let breaks = soft_hyphen_breaks("a\u{00AD}b\u{00AD}c");
131        assert_eq!(breaks, vec![3, 6]);
132    }
133
134    #[test]
135    fn soft_hyphen_at_start() {
136        // "\u{00AD}abc": soft hyphen at byte 0 (2 bytes), 'a' at byte 2
137        let breaks = soft_hyphen_breaks("\u{00AD}abc");
138        assert_eq!(breaks, vec![2]);
139    }
140
141    #[test]
142    fn soft_hyphen_consecutive() {
143        // "a\u{00AD}\u{00AD}b": two consecutive soft hyphens
144        // After 1st SHY: byte 3, after 2nd SHY: byte 5
145        let breaks = soft_hyphen_breaks("a\u{00AD}\u{00AD}b");
146        assert_eq!(breaks, vec![3, 5]);
147    }
148
149    #[cfg(feature = "hyphenation")]
150    mod hyphenation_feature {
151        use super::*;
152        use hypher::Lang;
153
154        #[test]
155        fn automatic_breaks_machine() {
156            let breaks = automatic_hyphen_breaks("machine", Lang::English);
157            // "machine" -> ["ma", "chine"], break at byte 2
158            assert_eq!(breaks, vec![2]);
159        }
160
161        #[test]
162        fn automatic_breaks_empty() {
163            let breaks = automatic_hyphen_breaks("", Lang::English);
164            assert!(breaks.is_empty());
165        }
166
167        #[test]
168        fn automatic_breaks_short_word() {
169            // Very short words may not hyphenate
170            let breaks = automatic_hyphen_breaks("I", Lang::English);
171            // Either empty or single break — just check it doesn't panic
172            let _ = breaks;
173        }
174
175        #[test]
176        fn automatic_breaks_sentence() {
177            // Multiple words: break points are relative to the full string
178            let breaks = automatic_hyphen_breaks("hyphenation machine", Lang::English);
179            // Verify all offsets are within string bounds
180            for &b in &breaks {
181                assert!(b <= "hyphenation machine".len(), "break {b} out of bounds");
182            }
183            // Should have at least one break (hyphenation has multiple syllables)
184            assert!(!breaks.is_empty());
185        }
186    }
187}