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}