1use unicode_linebreak::BreakOpportunity;
11
12#[derive(Debug, Clone)]
14pub enum Item {
15 Box {
17 width: f64,
18 char_start: usize,
20 char_end: usize,
21 },
22 Glue {
24 width: f64,
25 stretch: f64,
26 shrink: f64,
27 char_index: usize,
29 },
30 Penalty {
32 width: f64,
33 penalty: f64,
34 flagged: bool,
35 char_index: usize,
37 },
38}
39
40#[derive(Debug, Clone)]
42pub struct Config {
43 pub line_width: f64,
44 pub tolerance: f64,
46 pub hyphen_penalty: f64,
48 pub double_hyphen_demerits: f64,
50 pub fitness_demerits: f64,
52}
53
54impl Default for Config {
55 fn default() -> Self {
56 Self {
57 line_width: 0.0,
58 tolerance: 2.0,
59 hyphen_penalty: 50.0,
60 double_hyphen_demerits: 3000.0,
61 fitness_demerits: 100.0,
62 }
63 }
64}
65
66#[derive(Debug, Clone)]
68pub struct LineSolution {
69 pub break_item: usize,
71 pub adjustment_ratio: f64,
74 pub is_hyphenated: bool,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80enum FitnessClass {
81 Tight = 0,
82 Normal = 1,
83 Loose = 2,
84 VeryLoose = 3,
85}
86
87fn fitness_class(ratio: f64) -> FitnessClass {
88 if ratio < -0.5 {
89 FitnessClass::Tight
90 } else if ratio <= 0.5 {
91 FitnessClass::Normal
92 } else if ratio <= 1.0 {
93 FitnessClass::Loose
94 } else {
95 FitnessClass::VeryLoose
96 }
97}
98
99#[derive(Debug, Clone)]
101struct Breakpoint {
102 item_index: usize,
104 line: usize,
106 fitness: FitnessClass,
108 total_width: f64,
110 total_stretch: f64,
112 total_shrink: f64,
114 total_demerits: f64,
116 prev: Option<usize>,
118 is_hyphenated: bool,
120}
121
122pub fn build_items(
127 chars: &[char],
128 char_widths: &[f64],
129 hyphen_width: f64,
130 hyphens: crate::style::Hyphens,
131 break_opps: &[Option<BreakOpportunity>],
132 lang: Option<&str>,
133) -> Vec<Item> {
134 let mut items = Vec::new();
135 let mut box_start = 0;
136 let mut box_width = 0.0;
137 let _space_width = if !char_widths.is_empty() {
138 chars
140 .iter()
141 .zip(char_widths.iter())
142 .find(|(c, _)| **c == ' ')
143 .map(|(_, w)| *w)
144 .unwrap_or(char_widths[0])
145 } else {
146 0.0
147 };
148
149 let mut i = 0;
150 while i < chars.len() {
151 let ch = chars[i];
152
153 if ch == '\u{00AD}' {
155 if box_width > 0.0 {
157 items.push(Item::Box {
158 width: box_width,
159 char_start: box_start,
160 char_end: i,
161 });
162 box_width = 0.0;
163 }
164 if hyphens != crate::style::Hyphens::None {
165 items.push(Item::Penalty {
166 width: hyphen_width,
167 penalty: 50.0,
168 flagged: true,
169 char_index: i,
170 });
171 }
172 i += 1;
173 box_start = i;
174 continue;
175 }
176
177 if ch == '\n' || ch == '\r' || ch == '\u{2028}' || ch == '\u{2029}' {
179 if box_width > 0.0 {
180 items.push(Item::Box {
181 width: box_width,
182 char_start: box_start,
183 char_end: i,
184 });
185 box_width = 0.0;
186 }
187 items.push(Item::Penalty {
189 width: 0.0,
190 penalty: f64::NEG_INFINITY,
191 flagged: false,
192 char_index: i,
193 });
194 i += 1;
195 box_start = i;
196 continue;
197 }
198
199 if i > 0 {
201 if let Some(opp) = break_opps[i] {
202 match opp {
203 BreakOpportunity::Mandatory => {
204 if box_width > 0.0 {
205 items.push(Item::Box {
206 width: box_width,
207 char_start: box_start,
208 char_end: i,
209 });
210 box_width = 0.0;
211 }
212 items.push(Item::Penalty {
213 width: 0.0,
214 penalty: f64::NEG_INFINITY,
215 flagged: false,
216 char_index: i,
217 });
218 box_start = i;
219 }
220 BreakOpportunity::Allowed => {
221 let prev_is_space = i > 0 && chars[i - 1] == ' ';
225 if !prev_is_space {
226 if box_width > 0.0 {
227 items.push(Item::Box {
228 width: box_width,
229 char_start: box_start,
230 char_end: i,
231 });
232 box_width = 0.0;
233 box_start = i;
234 }
235 items.push(Item::Penalty {
236 width: 0.0,
237 penalty: 0.0,
238 flagged: false,
239 char_index: i,
240 });
241 }
242 }
243 }
244 }
245 }
246
247 if ch == ' ' || ch == '\t' {
249 if box_width > 0.0 {
250 items.push(Item::Box {
251 width: box_width,
252 char_start: box_start,
253 char_end: i,
254 });
255 box_width = 0.0;
256 }
257 let w = char_widths[i];
258 items.push(Item::Glue {
259 width: w,
260 stretch: w * 0.5,
261 shrink: w * 0.33,
262 char_index: i,
263 });
264 i += 1;
265 box_start = i;
266 continue;
267 }
268
269 box_width += char_widths[i];
271 i += 1;
272 }
273
274 if box_width > 0.0 {
276 items.push(Item::Box {
277 width: box_width,
278 char_start: box_start,
279 char_end: chars.len(),
280 });
281 }
282
283 if hyphens == crate::style::Hyphens::Auto {
285 items = insert_hyphenation_penalties(items, chars, char_widths, hyphen_width, lang);
286 }
287
288 items.push(Item::Glue {
290 width: 0.0,
291 stretch: 1e6,
292 shrink: 0.0,
293 char_index: chars.len(),
294 });
295 items.push(Item::Penalty {
296 width: 0.0,
297 penalty: f64::NEG_INFINITY,
298 flagged: false,
299 char_index: chars.len(),
300 });
301
302 items
303}
304
305fn insert_hyphenation_penalties(
307 items: Vec<Item>,
308 chars: &[char],
309 char_widths: &[f64],
310 hyphen_width: f64,
311 lang: Option<&str>,
312) -> Vec<Item> {
313 let hypher_lang = match super::resolve_hypher_lang(lang) {
314 Some(l) => l,
315 None => return items,
316 };
317
318 let mut result = Vec::with_capacity(items.len());
319
320 for item in items {
321 match &item {
322 Item::Box {
323 char_start,
324 char_end,
325 ..
326 } => {
327 let start = *char_start;
328 let end = *char_end;
329 let word: String = chars[start..end].iter().collect();
331 if word.len() < 4
332 || !word.chars().all(|c| c.is_alphabetic())
333 || word.chars().all(|c| c.is_whitespace())
334 {
335 result.push(item);
336 continue;
337 }
338
339 let syllables: Vec<&str> = hypher::hyphenate(&word, hypher_lang).collect();
340 if syllables.len() < 2 {
341 result.push(item);
342 continue;
343 }
344
345 let mut offset = start;
347 for (si, syllable) in syllables.iter().enumerate() {
348 let syl_len = syllable.chars().count();
349 let syl_end = offset + syl_len;
350 let syl_width: f64 = char_widths[offset..syl_end].iter().sum();
351
352 result.push(Item::Box {
353 width: syl_width,
354 char_start: offset,
355 char_end: syl_end,
356 });
357
358 if si < syllables.len() - 1 {
359 result.push(Item::Penalty {
360 width: hyphen_width,
361 penalty: 50.0,
362 flagged: true,
363 char_index: syl_end,
364 });
365 }
366
367 offset = syl_end;
368 }
369 }
370 _ => {
371 result.push(item);
372 }
373 }
374 }
375
376 result
377}
378
379pub fn find_breaks(items: &[Item], config: &Config) -> Option<Vec<LineSolution>> {
383 let mut breakpoints: Vec<Breakpoint> = Vec::new();
384 let mut active: Vec<usize> = Vec::new();
385
386 breakpoints.push(Breakpoint {
388 item_index: 0,
389 line: 0,
390 fitness: FitnessClass::Normal,
391 total_width: 0.0,
392 total_stretch: 0.0,
393 total_shrink: 0.0,
394 total_demerits: 0.0,
395 prev: None,
396 is_hyphenated: false,
397 });
398 active.push(0);
399
400 let mut total_width = 0.0;
402 let mut total_stretch = 0.0;
403 let mut total_shrink = 0.0;
404
405 for (i, item) in items.iter().enumerate() {
406 let is_break = match item {
410 Item::Penalty { penalty, .. } => *penalty < f64::INFINITY,
411 Item::Glue { .. } => {
412 i > 0 && matches!(items[i - 1], Item::Box { .. })
414 }
415 _ => false,
416 };
417
418 if is_break {
419 let mut new_active = Vec::new();
421 let mut best_by_fitness: [Option<(f64, usize)>; 4] = [None; 4];
422
423 let mut deactivate = Vec::new();
424
425 for &a_idx in &active {
426 let a = &breakpoints[a_idx];
427
428 let line_width = total_width - a.total_width;
431 let line_stretch = total_stretch - a.total_stretch;
432 let line_shrink = total_shrink - a.total_shrink;
433
434 let penalty_width = match item {
436 Item::Penalty { width, .. } => *width,
437 _ => 0.0,
438 };
439
440 let actual_width = line_width + penalty_width;
441 let target = config.line_width;
442
443 let ratio = if actual_width < target {
444 if line_stretch > 0.0 {
446 (target - actual_width) / line_stretch
447 } else {
448 f64::INFINITY
449 }
450 } else if actual_width > target {
451 if line_shrink > 0.0 {
453 (target - actual_width) / line_shrink
454 } else {
455 f64::INFINITY
456 }
457 } else {
458 0.0 };
460
461 if ratio < -1.0 || ratio > config.tolerance {
463 if ratio < -1.0 {
466 deactivate.push(a_idx);
467 }
468 continue;
469 }
470
471 let flagged = match item {
473 Item::Penalty { flagged, .. } => *flagged,
474 _ => false,
475 };
476 let penalty_val = match item {
480 Item::Penalty { .. } if flagged => config.hyphen_penalty,
481 Item::Penalty { penalty, .. } => *penalty,
482 _ => 0.0,
483 };
484
485 let badness = 100.0 * ratio.abs().powi(3);
486 let demerits = if penalty_val >= 0.0 {
487 (1.0 + badness + penalty_val).powi(2)
488 } else if penalty_val > f64::NEG_INFINITY {
489 (1.0 + badness).powi(2) - penalty_val.powi(2)
490 } else {
491 (1.0 + badness).powi(2)
492 };
493
494 let demerits = if flagged && a.is_hyphenated {
496 demerits + config.double_hyphen_demerits
497 } else {
498 demerits
499 };
500
501 let fc = fitness_class(ratio);
503 let demerits = if (fc as i32 - a.fitness as i32).unsigned_abs() > 1 {
504 demerits + config.fitness_demerits
505 } else {
506 demerits
507 };
508
509 let total = a.total_demerits + demerits;
510
511 let slot = fc as usize;
512 if best_by_fitness[slot].is_none() || total < best_by_fitness[slot].unwrap().0 {
513 best_by_fitness[slot] = Some((total, a_idx));
514 }
515 }
516
517 for d in &deactivate {
519 active.retain(|x| x != d);
520 }
521
522 let (bp_tw, bp_ts, bp_tk) = match item {
525 Item::Glue {
526 width,
527 stretch,
528 shrink,
529 ..
530 } => (
531 total_width + width,
532 total_stretch + stretch,
533 total_shrink + shrink,
534 ),
535 _ => (total_width, total_stretch, total_shrink),
536 };
537
538 for (fc_idx, best) in best_by_fitness.iter().enumerate() {
540 if let Some((total_demerits, prev_idx)) = best {
541 let is_hyph = matches!(item, Item::Penalty { flagged: true, .. });
542 let bp_idx = breakpoints.len();
543 breakpoints.push(Breakpoint {
544 item_index: i,
545 line: breakpoints[*prev_idx].line + 1,
546 fitness: match fc_idx {
547 0 => FitnessClass::Tight,
548 1 => FitnessClass::Normal,
549 2 => FitnessClass::Loose,
550 _ => FitnessClass::VeryLoose,
551 },
552 total_width: bp_tw,
553 total_stretch: bp_ts,
554 total_shrink: bp_tk,
555 total_demerits: *total_demerits,
556 prev: Some(*prev_idx),
557 is_hyphenated: is_hyph,
558 });
559 new_active.push(bp_idx);
560 }
561 }
562
563 active.extend(new_active);
564
565 if active.is_empty() {
567 return None;
568 }
569 }
570
571 match item {
573 Item::Box { width, .. } => {
574 total_width += width;
575 }
576 Item::Glue {
577 width,
578 stretch,
579 shrink,
580 ..
581 } => {
582 total_width += width;
583 total_stretch += stretch;
584 total_shrink += shrink;
585 }
586 Item::Penalty { .. } => {}
587 }
588 }
589
590 let last_item = items.len() - 1;
594 let best_final = active
595 .iter()
596 .filter(|&&idx| breakpoints[idx].item_index == last_item)
597 .min_by(|&&a, &&b| {
598 breakpoints[a]
599 .total_demerits
600 .partial_cmp(&breakpoints[b].total_demerits)
601 .unwrap_or(std::cmp::Ordering::Equal)
602 })
603 .copied()?;
604
605 let mut solutions = Vec::new();
607 let mut current = Some(best_final);
608
609 while let Some(idx) = current {
610 let bp = &breakpoints[idx];
611 if let Some(prev_idx) = bp.prev {
612 let prev_bp = &breakpoints[prev_idx];
614
615 let (glue_w, glue_st, glue_sh) = match &items[bp.item_index] {
619 Item::Glue {
620 width,
621 stretch,
622 shrink,
623 ..
624 } => (*width, *stretch, *shrink),
625 _ => (0.0, 0.0, 0.0),
626 };
627 let line_w = bp.total_width - prev_bp.total_width - glue_w;
628 let line_stretch = bp.total_stretch - prev_bp.total_stretch - glue_st;
629 let line_shrink = bp.total_shrink - prev_bp.total_shrink - glue_sh;
630
631 let penalty_w = match &items[bp.item_index] {
632 Item::Penalty { width, .. } => *width,
633 _ => 0.0,
634 };
635
636 let actual = line_w + penalty_w;
637 let target = config.line_width;
638 let ratio = if actual < target && line_stretch > 0.0 {
639 (target - actual) / line_stretch
640 } else if actual > target && line_shrink > 0.0 {
641 (target - actual) / line_shrink
642 } else {
643 0.0
644 };
645
646 solutions.push(LineSolution {
647 break_item: bp.item_index,
648 adjustment_ratio: ratio,
649 is_hyphenated: bp.is_hyphenated,
650 });
651 }
652 current = bp.prev;
653 }
654
655 solutions.reverse();
656 Some(solutions)
657}
658
659pub fn reconstruct_lines(
664 solutions: &[LineSolution],
665 items: &[Item],
666 chars: &[char],
667 char_widths: &[f64],
668 line_width: f64,
669 justify: bool,
670) -> Vec<super::BrokenLine> {
671 let mut lines = Vec::new();
672 let mut item_start = 0;
673
674 for (sol_idx, sol) in solutions.iter().enumerate() {
675 let is_last_line = sol_idx == solutions.len() - 1;
676 let apply_justify = justify && !is_last_line;
677
678 let mut line_chars = Vec::new();
679 let mut line_positions = Vec::new();
680 let mut x = 0.0;
681
682 for (item_idx, item) in items
683 .iter()
684 .enumerate()
685 .take(sol.break_item + 1)
686 .skip(item_start)
687 {
688 match item {
689 Item::Box {
690 char_start,
691 char_end,
692 ..
693 } => {
694 for ci in *char_start..*char_end {
695 if chars[ci] == '\u{00AD}' {
696 continue;
697 }
698 line_chars.push(chars[ci]);
699 line_positions.push(x);
700 x += char_widths[ci];
701 }
702 }
703 Item::Glue {
704 width,
705 stretch,
706 shrink,
707 char_index,
708 } => {
709 if item_idx == sol.break_item {
711 continue;
712 }
713 let natural = char_widths.get(*char_index).copied().unwrap_or(*width);
718 let adjusted = if apply_justify {
719 if sol.adjustment_ratio >= 0.0 {
720 natural + sol.adjustment_ratio * stretch
721 } else {
722 natural + sol.adjustment_ratio * shrink
723 }
724 } else {
725 natural
726 };
727 if *char_index < chars.len() {
728 line_chars.push(chars[*char_index]);
729 line_positions.push(x);
730 }
731 x += adjusted;
732 }
733 Item::Penalty { flagged, width, .. } => {
734 if item_idx == sol.break_item && *flagged {
736 line_chars.push('-');
737 line_positions.push(x);
738 x += width;
739 }
740 }
741 }
742 }
743
744 let mut effective_width = x;
746 let mut trim_idx = line_chars.len();
747 while trim_idx > 0 && line_chars[trim_idx - 1] == ' ' {
748 trim_idx -= 1;
749 if trim_idx < line_positions.len() {
750 let pos = line_positions[trim_idx];
751 effective_width = pos;
752 }
753 }
754
755 if apply_justify {
757 effective_width = effective_width.min(line_width);
758 }
759
760 lines.push(super::BrokenLine {
761 text: line_chars.iter().collect(),
762 chars: line_chars,
763 char_positions: line_positions,
764 width: effective_width,
765 hard_break: false,
766 });
767
768 item_start = sol.break_item + 1;
771 while item_start < items.len() && matches!(items[item_start], Item::Glue { .. }) {
772 item_start += 1;
773 }
774 }
775
776 lines
777}
778
779pub fn hang_trailing_letter_spacing(items: &mut [Item], letter_spacing_at: impl Fn(usize) -> f64) {
791 for i in 1..items.len() {
792 let (prev, rest) = items.split_at_mut(i);
793 let Item::Box {
794 width: box_width,
795 char_end,
796 ..
797 } = &mut prev[i - 1]
798 else {
799 continue;
800 };
801 let ls = letter_spacing_at(*char_end - 1);
802 if ls == 0.0 {
803 continue;
804 }
805 let ends_paragraph = matches!(
808 rest.get(1),
809 Some(Item::Penalty { penalty, .. }) if *penalty == f64::NEG_INFINITY
810 );
811 match &mut rest[0] {
812 Item::Glue {
813 width, char_index, ..
814 } if *char_index == *char_end => {
815 *box_width -= ls;
816 if !ends_paragraph {
817 *width += ls;
818 }
819 }
820 Item::Penalty {
821 width,
822 flagged: false,
823 char_index,
824 ..
825 } if *char_index == *char_end => {
826 *width -= ls;
827 }
828 _ => {}
829 }
830 }
831}
832
833pub fn disallow_shrink(items: &mut [Item]) {
841 for item in items {
842 if let Item::Glue { shrink, .. } = item {
843 *shrink = 0.0;
844 }
845 }
846}
847
848pub fn build_items_styled(
850 chars: &[super::StyledChar],
851 char_widths: &[f64],
852 hyphen_width: f64,
853 hyphens: crate::style::Hyphens,
854 break_opps: &[Option<BreakOpportunity>],
855 lang: Option<&str>,
856) -> Vec<Item> {
857 let plain_chars: Vec<char> = chars.iter().map(|sc| sc.ch).collect();
858 build_items(
859 &plain_chars,
860 char_widths,
861 hyphen_width,
862 hyphens,
863 break_opps,
864 lang,
865 )
866}
867
868pub fn reconstruct_run_lines(
870 solutions: &[LineSolution],
871 items: &[Item],
872 chars: &[super::StyledChar],
873 char_widths: &[f64],
874 line_width: f64,
875 justify: bool,
876) -> Vec<super::RunBrokenLine> {
877 let mut lines = Vec::new();
878 let mut item_start = 0;
879
880 for (sol_idx, sol) in solutions.iter().enumerate() {
881 let is_last_line = sol_idx == solutions.len() - 1;
882 let apply_justify = justify && !is_last_line;
883
884 let mut line_chars: Vec<super::StyledChar> = Vec::new();
885 let mut line_positions = Vec::new();
886 let mut x = 0.0;
887
888 for (item_idx, item) in items
889 .iter()
890 .enumerate()
891 .take(sol.break_item + 1)
892 .skip(item_start)
893 {
894 match item {
895 Item::Box {
896 char_start,
897 char_end,
898 ..
899 } => {
900 for ci in *char_start..*char_end {
901 if chars[ci].ch == '\u{00AD}' {
902 continue;
903 }
904 line_chars.push(chars[ci].clone());
905 line_positions.push(x);
906 x += char_widths[ci];
907 }
908 }
909 Item::Glue {
910 width,
911 stretch,
912 shrink,
913 char_index,
914 } => {
915 if item_idx == sol.break_item {
916 continue;
917 }
918 let natural = char_widths.get(*char_index).copied().unwrap_or(*width);
923 let adjusted = if apply_justify {
924 if sol.adjustment_ratio >= 0.0 {
925 natural + sol.adjustment_ratio * stretch
926 } else {
927 natural + sol.adjustment_ratio * shrink
928 }
929 } else {
930 natural
931 };
932 if *char_index < chars.len() {
933 line_chars.push(chars[*char_index].clone());
934 line_positions.push(x);
935 }
936 x += adjusted;
937 }
938 Item::Penalty {
939 flagged,
940 char_index,
941 width,
942 ..
943 } => {
944 if item_idx == sol.break_item && *flagged {
945 let style_ref = if *char_index > 0 {
947 &chars[char_index - 1]
948 } else {
949 &chars[0]
950 };
951 let mut hyphen_sc = style_ref.clone();
952 hyphen_sc.ch = '-';
953 line_chars.push(hyphen_sc);
954 line_positions.push(x);
955 x += width;
956 }
957 }
958 }
959 }
960
961 let mut effective_width = x;
963 let mut trim_idx = line_chars.len();
964 while trim_idx > 0 && line_chars[trim_idx - 1].ch == ' ' {
965 trim_idx -= 1;
966 if trim_idx < line_positions.len() {
967 effective_width = line_positions[trim_idx];
968 }
969 }
970
971 if apply_justify {
972 effective_width = effective_width.min(line_width);
973 }
974
975 lines.push(super::RunBrokenLine {
976 chars: line_chars,
977 char_positions: line_positions,
978 width: effective_width,
979 hard_break: false,
980 });
981
982 item_start = sol.break_item + 1;
983 while item_start < items.len() && matches!(items[item_start], Item::Glue { .. }) {
984 item_start += 1;
985 }
986 }
987
988 lines
989}
990
991#[cfg(test)]
992mod tests {
993 use super::*;
994
995 fn simple_items(text: &str, char_width: f64) -> (Vec<char>, Vec<f64>) {
996 let chars: Vec<char> = text.chars().collect();
997 let widths = vec![char_width; chars.len()];
998 (chars, widths)
999 }
1000
1001 #[test]
1007 fn test_config_hyphen_penalty_is_applied() {
1008 let text = "Internationalization considerations notwithstanding, comprehensive documentation remains extraordinarily indispensable.";
1009 let (chars, widths) = simple_items(text, 5.0);
1010 let break_opps = super::super::compute_break_opportunities(text);
1011 let items = build_items(
1012 &chars,
1013 &widths,
1014 5.0,
1015 crate::style::Hyphens::Auto,
1016 &break_opps,
1017 Some("en"),
1018 );
1019 let hyphenated = |line_width: f64, penalty: f64| -> usize {
1020 let config = Config {
1021 line_width,
1022 hyphen_penalty: penalty,
1023 ..Default::default()
1024 };
1025 find_breaks(&items, &config)
1026 .map(|s| s.iter().filter(|s| s.is_hyphenated).count())
1027 .unwrap_or(0)
1028 };
1029 let mut more_somewhere = false;
1030 for w in (150..=320).step_by(5) {
1031 let w = w as f64;
1032 let default = hyphenated(w, Config::default().hyphen_penalty);
1033 let rewarded = hyphenated(w, -1000.0);
1034 assert!(
1035 rewarded >= default,
1036 "width {w}: {rewarded} hyphens rewarded vs {default} by default"
1037 );
1038 more_somewhere |= rewarded > default;
1039 }
1040 assert!(
1041 more_somewhere,
1042 "hyphen_penalty changed nothing at any width: the setting is not applied"
1043 );
1044 }
1045
1046 #[test]
1047 fn test_build_items_simple() {
1048 let (chars, widths) = simple_items("Hello World", 10.0);
1049 let break_opps = super::super::compute_break_opportunities("Hello World");
1050 let items = build_items(
1051 &chars,
1052 &widths,
1053 5.0,
1054 crate::style::Hyphens::Manual,
1055 &break_opps,
1056 None,
1057 );
1058 let boxes: Vec<_> = items
1060 .iter()
1061 .filter(|i| matches!(i, Item::Box { .. }))
1062 .collect();
1063 assert_eq!(boxes.len(), 2, "Should have 2 boxes (Hello, World)");
1064 }
1065
1066 #[test]
1067 fn test_find_breaks_single_line() {
1068 let (chars, widths) = simple_items("Hello World", 10.0);
1069 let break_opps = super::super::compute_break_opportunities("Hello World");
1070 let items = build_items(
1071 &chars,
1072 &widths,
1073 5.0,
1074 crate::style::Hyphens::Manual,
1075 &break_opps,
1076 None,
1077 );
1078 let config = Config {
1079 line_width: 200.0, ..Default::default()
1081 };
1082 let solutions = find_breaks(&items, &config).expect("Should find solution");
1083 assert_eq!(solutions.len(), 1, "Should be 1 line");
1084 }
1085
1086 #[test]
1087 fn test_find_breaks_multi_line() {
1088 let (chars, widths) = simple_items("aa bb cc dd ee", 10.0);
1089 let break_opps = super::super::compute_break_opportunities("aa bb cc dd ee");
1090 let items = build_items(
1091 &chars,
1092 &widths,
1093 5.0,
1094 crate::style::Hyphens::Manual,
1095 &break_opps,
1096 None,
1097 );
1098 let config = Config {
1099 line_width: 55.0, ..Default::default()
1101 };
1102 let solutions = find_breaks(&items, &config).expect("Should find solution");
1103 assert!(
1104 solutions.len() >= 2,
1105 "Should need multiple lines, got {}",
1106 solutions.len()
1107 );
1108 }
1109
1110 #[test]
1111 fn test_adjustment_ratio() {
1112 let (chars, widths) = simple_items("Hello World", 10.0);
1113 let break_opps = super::super::compute_break_opportunities("Hello World");
1114 let items = build_items(
1115 &chars,
1116 &widths,
1117 5.0,
1118 crate::style::Hyphens::Manual,
1119 &break_opps,
1120 None,
1121 );
1122 let config = Config {
1123 line_width: 200.0,
1124 ..Default::default()
1125 };
1126 let solutions = find_breaks(&items, &config).expect("Should find solution");
1127 for sol in &solutions {
1129 assert!(
1130 sol.adjustment_ratio.is_finite(),
1131 "Adjustment ratio should be finite"
1132 );
1133 }
1134 }
1135
1136 #[test]
1137 fn test_justify_spacing() {
1138 let (chars, widths) = simple_items("aa bb cc dd ee", 10.0);
1140 let break_opps = super::super::compute_break_opportunities("aa bb cc dd ee");
1141 let items = build_items(
1142 &chars,
1143 &widths,
1144 5.0,
1145 crate::style::Hyphens::Manual,
1146 &break_opps,
1147 None,
1148 );
1149 let config = Config {
1150 line_width: 55.0,
1151 ..Default::default()
1152 };
1153 let solutions = find_breaks(&items, &config).expect("Should find solution");
1154 let lines = reconstruct_lines(&solutions, &items, &chars, &widths, 55.0, true);
1155 assert!(lines.len() >= 2);
1156 for (i, line) in lines.iter().enumerate() {
1158 if i < lines.len() - 1 {
1159 assert!(
1160 (line.width - 55.0).abs() < 1.0,
1161 "Justified line {} width should be ~55, got {}",
1162 i,
1163 line.width
1164 );
1165 }
1166 }
1167 }
1168
1169 #[test]
1170 fn test_hyphenation_penalty() {
1171 let (chars, widths) = simple_items("extraordinary", 10.0);
1172 let break_opps = super::super::compute_break_opportunities("extraordinary");
1173 let items = build_items(
1174 &chars,
1175 &widths,
1176 5.0,
1177 crate::style::Hyphens::Auto,
1178 &break_opps,
1179 None,
1180 );
1181 let penalties: Vec<_> = items
1182 .iter()
1183 .filter(|i| matches!(i, Item::Penalty { flagged: true, .. }))
1184 .collect();
1185 assert!(
1186 !penalties.is_empty(),
1187 "Should have hyphenation penalties for 'extraordinary'"
1188 );
1189 }
1190}