@@ -170,11 +170,10 @@ fn chunk(text: &str) -> Vec<String> {
170170 current. push_str ( "\n \n " ) ;
171171 }
172172 current. push_str ( para) ;
173- // Hard-split oversized paragraphs on word boundaries.
173+ // Hard-split oversized paragraphs, cutting only at UTF-8 char
174+ // boundaries so multi-byte text never panics the slice.
174175 while current. len ( ) > CHUNK_MAX {
175- let cut = current[ ..CHUNK_MAX ]
176- . rfind ( char:: is_whitespace)
177- . unwrap_or ( CHUNK_MAX ) ;
176+ let cut = split_point ( & current) ;
178177 let rest = current. split_off ( cut) ;
179178 chunks. push ( std:: mem:: take ( & mut current) ) ;
180179 current = rest. trim_start ( ) . to_owned ( ) ;
@@ -186,6 +185,32 @@ fn chunk(text: &str) -> Vec<String> {
186185 chunks
187186}
188187
188+ /// Byte index at or below `CHUNK_MAX` at which to split a too-long chunk.
189+ ///
190+ /// Prefers the last whitespace boundary within the limit and always lands on a
191+ /// UTF-8 char boundary, so a chunk containing multi-byte characters cannot
192+ /// trigger a panic when sliced. The result is at least 1, so the split loop
193+ /// always makes progress.
194+ fn split_point ( s : & str ) -> usize {
195+ if let Some ( ( idx, _) ) = s
196+ . char_indices ( )
197+ . take_while ( |( i, _) | * i <= CHUNK_MAX )
198+ . filter ( |( _, c) | c. is_whitespace ( ) )
199+ . last ( )
200+ {
201+ if idx > 0 {
202+ return idx;
203+ }
204+ }
205+ // No usable whitespace in range: fall back to the largest char boundary
206+ // at or below the limit.
207+ let mut cut = CHUNK_MAX ;
208+ while cut > 1 && !s. is_char_boundary ( cut) {
209+ cut -= 1 ;
210+ }
211+ cut
212+ }
213+
189214/// Heuristic named-entity extraction: runs of capitalized words.
190215///
191216/// A real pipeline would use an NER model or an LLM here; runs of TitleCase
@@ -258,4 +283,19 @@ mod tests {
258283 assert_eq ! ( chunks. len( ) , 1 ) ;
259284 assert ! ( chunks[ 0 ] . contains( "para three" ) ) ;
260285 }
286+
287+ #[ test]
288+ fn chunking_splits_multibyte_text_without_panic ( ) {
289+ // A whitespace-free run of 3-byte characters: byte CHUNK_MAX lands
290+ // mid-character, which a naive byte slice would panic on.
291+ let text = "\u{4f60} " . repeat ( 400 ) ; // 1200 bytes, 400 chars
292+ let chunks = chunk ( & text) ;
293+ assert ! (
294+ chunks. len( ) > 1 ,
295+ "expected a hard split, got {}" ,
296+ chunks. len( )
297+ ) ;
298+ // No whitespace in the input, so the split is lossless.
299+ assert_eq ! ( chunks. concat( ) . chars( ) . count( ) , 400 ) ;
300+ }
261301}
0 commit comments