1use std::collections::HashMap;
5
6#[derive(Debug, Clone, PartialEq)]
8pub struct LineBreakConfig {
9 pub max_chars_per_line: u8,
11 pub max_cps: f32,
13 pub max_lines: u8,
15 pub min_gap_ms: u32,
17 pub hard_max_chars: Option<u8>,
20}
21
22impl LineBreakConfig {
23 pub fn default_broadcast() -> Self {
25 Self {
26 max_chars_per_line: 42,
27 max_cps: 17.0,
28 max_lines: 2,
29 min_gap_ms: 80,
30 hard_max_chars: None,
31 }
32 }
33
34 pub fn effective_max_chars(&self) -> u8 {
36 match self.hard_max_chars {
37 Some(hard) => self.max_chars_per_line.min(hard),
38 None => self.max_chars_per_line,
39 }
40 }
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum AudienceProfile {
48 YoungChildren,
50 OlderChildren,
52 Adults,
54 TechnicalAdults,
56}
57
58impl AudienceProfile {
59 pub fn max_cps(self) -> f32 {
61 match self {
62 AudienceProfile::YoungChildren => 5.0,
63 AudienceProfile::OlderChildren => 10.0,
64 AudienceProfile::Adults => 17.0,
65 AudienceProfile::TechnicalAdults => 22.0,
66 }
67 }
68
69 pub fn min_display_ms(self) -> u32 {
71 match self {
72 AudienceProfile::YoungChildren => 3000,
73 AudienceProfile::OlderChildren => 1500,
74 AudienceProfile::Adults => 1000,
75 AudienceProfile::TechnicalAdults => 700,
76 }
77 }
78}
79
80pub fn reading_speed_ok_for_audience(
84 text: &str,
85 duration_ms: u64,
86 audience: AudienceProfile,
87) -> bool {
88 reading_speed_ok(text, duration_ms, audience.max_cps())
89}
90
91#[derive(Debug, Default)]
98pub struct CpsCache {
99 cache: HashMap<(u64, u64), f32>, }
101
102impl CpsCache {
103 pub fn new() -> Self {
105 Self::default()
106 }
107
108 pub fn compute_cps(&mut self, text: &str, duration_ms: u64) -> f32 {
110 let key = (hash_str(text), duration_ms);
111 *self
112 .cache
113 .entry(key)
114 .or_insert_with(|| compute_cps(text, duration_ms))
115 }
116
117 pub fn len(&self) -> usize {
119 self.cache.len()
120 }
121
122 pub fn is_empty(&self) -> bool {
124 self.cache.is_empty()
125 }
126
127 pub fn clear(&mut self) {
129 self.cache.clear();
130 }
131}
132
133fn hash_str(s: &str) -> u64 {
135 const FNV_OFFSET: u64 = 14695981039346656037;
136 const FNV_PRIME: u64 = 1099511628211;
137 s.bytes().fold(FNV_OFFSET, |acc, b| {
138 (acc ^ b as u64).wrapping_mul(FNV_PRIME)
139 })
140}
141
142fn is_cjk_char(ch: char) -> bool {
146 ('\u{4E00}'..='\u{9FFF}').contains(&ch)
148 || ('\u{3400}'..='\u{4DBF}').contains(&ch)
149 || ('\u{F900}'..='\u{FAFF}').contains(&ch)
150 || ('\u{3040}'..='\u{309F}').contains(&ch)
152 || ('\u{30A0}'..='\u{30FF}').contains(&ch)
153 || ('\u{AC00}'..='\u{D7AF}').contains(&ch)
155}
156
157fn is_cjk_no_start(ch: char) -> bool {
162 matches!(
163 ch,
164 '、' | '。'
165 | ','
166 | '.'
167 | ':'
168 | ';'
169 | '?'
170 | '!'
171 | ')'
172 | '」'
173 | '』'
174 | '】'
175 | '〕'
176 | '〉'
177 | '》'
178 | '·'
179 | '‥'
180 | '…'
181 | 'ー'
182 | 'ヽ'
183 | 'ヾ'
184 | 'ゝ'
185 | 'ゞ'
186 )
187}
188
189pub fn cjk_break(text: &str, max_width: u8) -> Vec<String> {
196 let max = max_width.max(1) as usize;
197 let chars: Vec<char> = text.chars().collect();
198 let n = chars.len();
199
200 if n <= max {
201 return vec![text.to_string()];
202 }
203
204 let mut lines: Vec<String> = Vec::new();
205 let mut start = 0;
206
207 while start < n {
208 let ideal_end = (start + max).min(n);
210
211 if ideal_end >= n {
212 lines.push(chars[start..].iter().collect());
213 break;
214 }
215
216 let mut end = ideal_end;
218 while end > start + 1 && is_cjk_no_start(chars[end]) {
219 end -= 1;
220 }
221
222 lines.push(chars[start..end].iter().collect());
223 start = end;
224 }
225
226 if lines.is_empty() {
227 lines.push(String::new());
228 }
229 lines
230}
231
232pub fn language_aware_break(text: &str, max_width: u8) -> Vec<String> {
240 let non_ws: Vec<char> = text.chars().filter(|c| !c.is_whitespace()).collect();
241 if non_ws.is_empty() {
242 return vec![String::new()];
243 }
244
245 let cjk_count = non_ws.iter().filter(|&&c| is_cjk_char(c)).count();
246 let cjk_fraction = cjk_count as f32 / non_ws.len() as f32;
247
248 if cjk_fraction > 0.30 {
249 cjk_break(text, max_width)
250 } else {
251 greedy_break(text, max_width)
252 }
253}
254
255#[derive(Debug, Clone, PartialEq)]
257pub enum LineBreakAlgorithm {
258 Greedy,
260 Optimal,
263 Fixed(u8),
265}
266
267pub fn greedy_break(text: &str, max_width: u8) -> Vec<String> {
273 let max = max_width.max(1) as usize;
274 let mut lines: Vec<String> = Vec::new();
275 let mut current = String::new();
276
277 for word in text.split_whitespace() {
278 if current.is_empty() {
279 current.push_str(word);
280 } else if current.chars().count() + 1 + word.chars().count() <= max {
281 current.push(' ');
282 current.push_str(word);
283 } else {
284 lines.push(current.clone());
285 current = word.to_string();
286 }
287 }
288 if !current.is_empty() {
289 lines.push(current);
290 }
291 if lines.is_empty() {
292 lines.push(String::new());
293 }
294 lines
295}
296
297pub fn optimal_break(text: &str, max_width: u8) -> Vec<String> {
304 let max = max_width.max(1) as usize;
305 let words: Vec<&str> = text.split_whitespace().collect();
306 let n = words.len();
307
308 if n == 0 {
309 return vec![String::new()];
310 }
311
312 let word_lens: Vec<usize> = words.iter().map(|w| w.chars().count()).collect();
315
316 let mut dp = vec![u64::MAX; n + 1];
319 let mut breaks: Vec<usize> = vec![n; n + 1];
320 dp[n] = 0;
321
322 for i in (0..n).rev() {
323 let mut width = 0usize;
324 for j in i..n {
325 width += word_lens[j];
326 if j > i {
327 width += 1; }
329 if width > max {
330 break;
331 }
332 let slack = max - width;
333 let line_cost = (slack * slack) as u64;
334 let rest_cost = dp[j + 1];
335 if rest_cost != u64::MAX {
336 let total = line_cost.saturating_add(rest_cost);
337 if total < dp[i] {
338 dp[i] = total;
339 breaks[i] = j + 1;
340 }
341 }
342 }
343 if dp[i] == u64::MAX {
345 dp[i] = 0;
346 breaks[i] = i + 1;
347 }
348 }
349
350 let mut lines: Vec<String> = Vec::new();
352 let mut pos = 0;
353 while pos < n {
354 let end = breaks[pos].min(n);
355 let end = if end <= pos { pos + 1 } else { end };
356 lines.push(words[pos..end].join(" "));
357 pos = end;
358 }
359 lines
360}
361
362pub fn compute_cps(text: &str, duration_ms: u64) -> f32 {
368 if duration_ms == 0 {
369 return 0.0;
370 }
371 let char_count = text.chars().count() as f32;
372 char_count / (duration_ms as f32 / 1000.0)
373}
374
375pub fn reading_speed_ok(text: &str, duration_ms: u64, max_cps: f32) -> bool {
378 compute_cps(text, duration_ms) <= max_cps
379}
380
381pub fn adjust_duration_for_reading(text: &str, min_ms: u32, max_cps: f32) -> u32 {
386 if max_cps <= 0.0 {
387 return min_ms;
388 }
389 let char_count = text.chars().count() as f32;
390 let required_ms = (char_count * 1000.0 / max_cps).ceil() as u32;
391 required_ms.max(min_ms)
392}
393
394pub struct LineBalance;
398
399impl LineBalance {
400 pub fn balance_factor(lines: &[String]) -> f32 {
407 if lines.len() <= 1 {
408 return 0.0;
409 }
410 let lengths: Vec<f32> = lines.iter().map(|l| l.chars().count() as f32).collect();
411 let mean = lengths.iter().sum::<f32>() / lengths.len() as f32;
412 if mean < 1e-6 {
413 return 0.0;
414 }
415 let variance =
416 lengths.iter().map(|&l| (l - mean).powi(2)).sum::<f32>() / lengths.len() as f32;
417 let std_dev = variance.sqrt();
418 (std_dev / mean).min(1.0)
420 }
421}
422
423pub fn rebalance_lines(lines: Vec<String>, max_width: u8) -> Vec<String> {
429 if lines.len() <= 1 {
430 return lines;
431 }
432
433 let original_factor = LineBalance::balance_factor(&lines);
434 let combined = lines.join(" ");
435 let rebroken = optimal_break(&combined, max_width);
436 let new_factor = LineBalance::balance_factor(&rebroken);
437
438 if new_factor < original_factor {
439 rebroken
440 } else {
441 lines
442 }
443}
444
445#[cfg(test)]
448mod tests {
449 use super::*;
450
451 #[test]
454 fn greedy_break_empty_string() {
455 let result = greedy_break("", 40);
456 assert_eq!(result, vec![""]);
457 }
458
459 #[test]
460 fn greedy_break_single_word_fits() {
461 let result = greedy_break("Hello", 40);
462 assert_eq!(result, vec!["Hello"]);
463 }
464
465 #[test]
466 fn greedy_break_two_words_fit_on_one_line() {
467 let result = greedy_break("Hello world", 20);
468 assert_eq!(result, vec!["Hello world"]);
469 }
470
471 #[test]
472 fn greedy_break_wraps_at_limit() {
473 let result = greedy_break("Hello world", 8);
474 assert_eq!(result, vec!["Hello", "world"]);
475 }
476
477 #[test]
478 fn greedy_break_multiple_lines() {
479 let result = greedy_break("one two three four five", 9);
480 assert!(result.len() >= 2);
482 for line in &result {
483 assert!(line.chars().count() <= 9, "line '{line}' exceeds max width");
484 }
485 }
486
487 #[test]
488 fn greedy_break_long_word_gets_own_line() {
489 let result = greedy_break("A superlongwordthatexceedslimit B", 10);
490 assert!(result.iter().any(|l| l.contains("superlongword")));
492 }
493
494 #[test]
495 fn greedy_break_preserves_all_words() {
496 let text = "one two three four five six seven";
497 let result = greedy_break(text, 15);
498 let rejoined = result.join(" ");
499 assert_eq!(rejoined, text);
500 }
501
502 #[test]
505 fn optimal_break_empty_string() {
506 let result = optimal_break("", 40);
507 assert_eq!(result, vec![""]);
508 }
509
510 #[test]
511 fn optimal_break_single_line() {
512 let result = optimal_break("Hello world", 20);
513 assert_eq!(result, vec!["Hello world"]);
514 }
515
516 #[test]
517 fn optimal_break_more_balanced_than_greedy() {
518 let text = "one two three four";
522 let optimal = optimal_break(text, 10);
523 let greedy = greedy_break(text, 10);
524 let opt_balance = LineBalance::balance_factor(&optimal);
525 let greed_balance = LineBalance::balance_factor(&greedy);
526 assert!(
528 opt_balance <= greed_balance + 0.01,
529 "optimal balance {opt_balance} worse than greedy {greed_balance}"
530 );
531 }
532
533 #[test]
534 fn optimal_break_preserves_all_words() {
535 let text = "alpha beta gamma delta epsilon zeta";
536 let result = optimal_break(text, 15);
537 let rejoined = result.join(" ");
538 assert_eq!(rejoined, text);
539 }
540
541 #[test]
542 fn optimal_break_no_line_exceeds_max_width() {
543 let text = "short lines should be wrapped correctly by algorithm";
544 let result = optimal_break(text, 20);
545 for line in &result {
546 assert!(
547 line.chars().count() <= 20,
548 "line '{line}' exceeds max width"
549 );
550 }
551 }
552
553 #[test]
556 fn compute_cps_basic() {
557 let cps = compute_cps("Hello wrld", 2000);
559 assert!((cps - 5.0).abs() < 0.01, "expected ~5.0, got {cps}");
560 }
561
562 #[test]
563 fn compute_cps_zero_duration_returns_zero() {
564 assert_eq!(compute_cps("Hello", 0), 0.0);
565 }
566
567 #[test]
568 fn compute_cps_empty_text() {
569 assert_eq!(compute_cps("", 1000), 0.0);
570 }
571
572 #[test]
575 fn reading_speed_ok_slow_enough() {
576 assert!(reading_speed_ok("Hello", 1000, 17.0));
578 }
579
580 #[test]
581 fn reading_speed_ok_too_fast() {
582 let long_text = "A".repeat(50);
584 assert!(!reading_speed_ok(&long_text, 1000, 17.0));
585 }
586
587 #[test]
590 fn adjust_duration_respects_min() {
591 let d = adjust_duration_for_reading("Hello", 1000, 17.0);
593 assert_eq!(d, 1000);
594 }
595
596 #[test]
597 fn adjust_duration_extends_for_long_text() {
598 let text = "A".repeat(170);
600 let d = adjust_duration_for_reading(&text, 1000, 17.0);
601 assert_eq!(d, 10000);
602 }
603
604 #[test]
605 fn adjust_duration_zero_max_cps_returns_min() {
606 let d = adjust_duration_for_reading("Hello world", 500, 0.0);
607 assert_eq!(d, 500);
608 }
609
610 #[test]
613 fn balance_factor_single_line_is_zero() {
614 let lines = vec!["Hello world".to_string()];
615 assert_eq!(LineBalance::balance_factor(&lines), 0.0);
616 }
617
618 #[test]
619 fn balance_factor_equal_lines_is_zero() {
620 let lines = vec!["Hello".to_string(), "World".to_string()];
621 assert!((LineBalance::balance_factor(&lines)).abs() < 1e-5);
622 }
623
624 #[test]
625 fn balance_factor_unequal_lines_nonzero() {
626 let lines = vec!["A".to_string(), "A much longer line here".to_string()];
627 assert!(LineBalance::balance_factor(&lines) > 0.0);
628 }
629
630 #[test]
631 fn balance_factor_empty_lines_is_zero() {
632 assert_eq!(LineBalance::balance_factor(&[]), 0.0);
633 }
634
635 #[test]
638 fn rebalance_lines_single_line_unchanged() {
639 let lines = vec!["Hello world".to_string()];
640 let result = rebalance_lines(lines.clone(), 40);
641 assert_eq!(result, lines);
642 }
643
644 #[test]
645 fn rebalance_lines_produces_at_most_same_balance_factor() {
646 let lines = vec![
647 "Hi".to_string(),
648 "This is a much longer second line here".to_string(),
649 ];
650 let original_factor = LineBalance::balance_factor(&lines);
651 let result = rebalance_lines(lines, 40);
652 let new_factor = LineBalance::balance_factor(&result);
653 assert!(new_factor <= original_factor + 0.01);
654 }
655
656 #[test]
657 fn rebalance_lines_preserves_all_words() {
658 let lines = vec!["one two".to_string(), "three four five six".to_string()];
659 let original_words: std::collections::HashSet<String> = lines
660 .iter()
661 .flat_map(|l| l.split_whitespace())
662 .map(|w| w.to_string())
663 .collect();
664 let result = rebalance_lines(lines, 20);
665 let result_words: std::collections::HashSet<String> = result
666 .iter()
667 .flat_map(|l| l.split_whitespace())
668 .map(|w| w.to_string())
669 .collect();
670 assert_eq!(original_words, result_words);
671 }
672
673 #[test]
674 fn line_break_config_default_broadcast_values() {
675 let cfg = LineBreakConfig::default_broadcast();
676 assert_eq!(cfg.max_chars_per_line, 42);
677 assert_eq!(cfg.max_lines, 2);
678 assert_eq!(cfg.min_gap_ms, 80);
679 assert_eq!(cfg.hard_max_chars, None);
680 }
681
682 #[test]
685 fn line_break_config_hard_max_chars_constrains_effective() {
686 let mut cfg = LineBreakConfig::default_broadcast();
687 cfg.hard_max_chars = Some(30);
688 assert_eq!(cfg.effective_max_chars(), 30); cfg.hard_max_chars = Some(50);
690 assert_eq!(cfg.effective_max_chars(), 42); }
692
693 #[test]
696 fn audience_profile_children_have_lower_cps() {
697 assert!(AudienceProfile::YoungChildren.max_cps() < AudienceProfile::Adults.max_cps());
698 assert!(AudienceProfile::OlderChildren.max_cps() < AudienceProfile::Adults.max_cps());
699 }
700
701 #[test]
702 fn audience_profile_children_have_longer_min_display() {
703 assert!(
704 AudienceProfile::YoungChildren.min_display_ms()
705 > AudienceProfile::Adults.min_display_ms()
706 );
707 }
708
709 #[test]
710 fn reading_speed_ok_for_audience_children() {
711 assert!(reading_speed_ok_for_audience(
713 "Hello world",
714 3000,
715 AudienceProfile::YoungChildren
716 ));
717 }
718
719 #[test]
720 fn reading_speed_too_fast_for_children() {
721 let text = "A".repeat(100);
723 assert!(!reading_speed_ok_for_audience(
724 &text,
725 2000,
726 AudienceProfile::YoungChildren
727 ));
728 }
729
730 #[test]
733 fn cps_cache_returns_same_value_twice() {
734 let mut cache = CpsCache::new();
735 let v1 = cache.compute_cps("Hello world", 2000);
736 let v2 = cache.compute_cps("Hello world", 2000);
737 assert!((v1 - v2).abs() < 1e-6);
738 }
739
740 #[test]
741 fn cps_cache_stores_entry() {
742 let mut cache = CpsCache::new();
743 assert_eq!(cache.len(), 0);
744 cache.compute_cps("Hello", 1000);
745 assert_eq!(cache.len(), 1);
746 cache.compute_cps("Hello", 1000);
748 assert_eq!(cache.len(), 1);
749 cache.compute_cps("World", 1000);
751 assert_eq!(cache.len(), 2);
752 }
753
754 #[test]
755 fn cps_cache_clear_removes_all_entries() {
756 let mut cache = CpsCache::new();
757 cache.compute_cps("Hello", 1000);
758 cache.clear();
759 assert!(cache.is_empty());
760 }
761
762 #[test]
765 fn cjk_break_short_text_unchanged() {
766 let text = "日本語";
767 let result = cjk_break(text, 10);
768 assert_eq!(result.len(), 1);
769 assert_eq!(result[0], text);
770 }
771
772 #[test]
773 fn cjk_break_long_text_splits_at_char_boundary() {
774 let text = "これは日本語のテキストサンプルです"; let result = cjk_break(text, 5);
776 assert!(result.len() > 1, "expected split");
777 for line in &result {
778 let count = line.chars().count();
779 assert!(count <= 5, "line '{line}' has {count} chars > 5");
780 }
781 let combined: String = result.concat();
783 assert_eq!(combined.chars().count(), text.chars().count());
784 }
785
786 #[test]
787 fn language_aware_break_latin_uses_greedy() {
788 let text = "Hello there how are you doing";
789 let result = language_aware_break(text, 12);
790 let rejoined = result.join(" ");
791 assert_eq!(rejoined, text);
792 }
793
794 #[test]
795 fn language_aware_break_cjk_detected() {
796 let text = "これは日本語のテキストです"; let result = language_aware_break(text, 5);
798 assert!(result.len() > 1, "expected multi-line CJK break");
799 }
800
801 #[test]
804 fn optimal_break_reference_output_known_case() {
805 let text = "one two three four five";
808 let result = optimal_break(text, 11);
809 let rejoined = result.join(" ");
811 assert_eq!(rejoined, text);
812 for line in &result {
814 assert!(
815 line.chars().count() <= 11,
816 "line '{line}' exceeds max width"
817 );
818 }
819 }
820
821 #[test]
822 fn greedy_and_optimal_produce_identical_single_line() {
823 let text = "Hello";
825 let g = greedy_break(text, 20);
826 let o = optimal_break(text, 20);
827 assert_eq!(g, o);
828 }
829
830 #[test]
831 fn greedy_and_optimal_identical_for_single_word_per_line() {
832 let text = "a b c";
834 let g = greedy_break(text, 1);
835 let o = optimal_break(text, 1);
836 assert_eq!(g.len(), o.len(), "g={:?} o={:?}", g, o);
838 }
839}