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 penalty_val = match item {
473 Item::Penalty { penalty, .. } => *penalty,
474 _ => 0.0,
475 };
476 let flagged = match item {
477 Item::Penalty { flagged, .. } => *flagged,
478 _ => false,
479 };
480
481 let badness = 100.0 * ratio.abs().powi(3);
482 let demerits = if penalty_val >= 0.0 {
483 (1.0 + badness + penalty_val).powi(2)
484 } else if penalty_val > f64::NEG_INFINITY {
485 (1.0 + badness).powi(2) - penalty_val.powi(2)
486 } else {
487 (1.0 + badness).powi(2)
488 };
489
490 let demerits = if flagged && a.is_hyphenated {
492 demerits + config.double_hyphen_demerits
493 } else {
494 demerits
495 };
496
497 let fc = fitness_class(ratio);
499 let demerits = if (fc as i32 - a.fitness as i32).unsigned_abs() > 1 {
500 demerits + config.fitness_demerits
501 } else {
502 demerits
503 };
504
505 let total = a.total_demerits + demerits;
506
507 let slot = fc as usize;
508 if best_by_fitness[slot].is_none() || total < best_by_fitness[slot].unwrap().0 {
509 best_by_fitness[slot] = Some((total, a_idx));
510 }
511 }
512
513 for d in &deactivate {
515 active.retain(|x| x != d);
516 }
517
518 let (bp_tw, bp_ts, bp_tk) = match item {
521 Item::Glue {
522 width,
523 stretch,
524 shrink,
525 ..
526 } => (
527 total_width + width,
528 total_stretch + stretch,
529 total_shrink + shrink,
530 ),
531 _ => (total_width, total_stretch, total_shrink),
532 };
533
534 for (fc_idx, best) in best_by_fitness.iter().enumerate() {
536 if let Some((total_demerits, prev_idx)) = best {
537 let is_hyph = matches!(item, Item::Penalty { flagged: true, .. });
538 let bp_idx = breakpoints.len();
539 breakpoints.push(Breakpoint {
540 item_index: i,
541 line: breakpoints[*prev_idx].line + 1,
542 fitness: match fc_idx {
543 0 => FitnessClass::Tight,
544 1 => FitnessClass::Normal,
545 2 => FitnessClass::Loose,
546 _ => FitnessClass::VeryLoose,
547 },
548 total_width: bp_tw,
549 total_stretch: bp_ts,
550 total_shrink: bp_tk,
551 total_demerits: *total_demerits,
552 prev: Some(*prev_idx),
553 is_hyphenated: is_hyph,
554 });
555 new_active.push(bp_idx);
556 }
557 }
558
559 active.extend(new_active);
560
561 if active.is_empty() {
563 return None;
564 }
565 }
566
567 match item {
569 Item::Box { width, .. } => {
570 total_width += width;
571 }
572 Item::Glue {
573 width,
574 stretch,
575 shrink,
576 ..
577 } => {
578 total_width += width;
579 total_stretch += stretch;
580 total_shrink += shrink;
581 }
582 Item::Penalty { .. } => {}
583 }
584 }
585
586 let last_item = items.len() - 1;
590 let best_final = active
591 .iter()
592 .filter(|&&idx| breakpoints[idx].item_index == last_item)
593 .min_by(|&&a, &&b| {
594 breakpoints[a]
595 .total_demerits
596 .partial_cmp(&breakpoints[b].total_demerits)
597 .unwrap_or(std::cmp::Ordering::Equal)
598 })
599 .copied()?;
600
601 let mut solutions = Vec::new();
603 let mut current = Some(best_final);
604
605 while let Some(idx) = current {
606 let bp = &breakpoints[idx];
607 if let Some(prev_idx) = bp.prev {
608 let prev_bp = &breakpoints[prev_idx];
610
611 let (glue_w, glue_st, glue_sh) = match &items[bp.item_index] {
615 Item::Glue {
616 width,
617 stretch,
618 shrink,
619 ..
620 } => (*width, *stretch, *shrink),
621 _ => (0.0, 0.0, 0.0),
622 };
623 let line_w = bp.total_width - prev_bp.total_width - glue_w;
624 let line_stretch = bp.total_stretch - prev_bp.total_stretch - glue_st;
625 let line_shrink = bp.total_shrink - prev_bp.total_shrink - glue_sh;
626
627 let penalty_w = match &items[bp.item_index] {
628 Item::Penalty { width, .. } => *width,
629 _ => 0.0,
630 };
631
632 let actual = line_w + penalty_w;
633 let target = config.line_width;
634 let ratio = if actual < target && line_stretch > 0.0 {
635 (target - actual) / line_stretch
636 } else if actual > target && line_shrink > 0.0 {
637 (target - actual) / line_shrink
638 } else {
639 0.0
640 };
641
642 solutions.push(LineSolution {
643 break_item: bp.item_index,
644 adjustment_ratio: ratio,
645 is_hyphenated: bp.is_hyphenated,
646 });
647 }
648 current = bp.prev;
649 }
650
651 solutions.reverse();
652 Some(solutions)
653}
654
655pub fn reconstruct_lines(
660 solutions: &[LineSolution],
661 items: &[Item],
662 chars: &[char],
663 char_widths: &[f64],
664 line_width: f64,
665 justify: bool,
666) -> Vec<super::BrokenLine> {
667 let mut lines = Vec::new();
668 let mut item_start = 0;
669
670 for (sol_idx, sol) in solutions.iter().enumerate() {
671 let is_last_line = sol_idx == solutions.len() - 1;
672 let apply_justify = justify && !is_last_line;
673
674 let mut line_chars = Vec::new();
675 let mut line_positions = Vec::new();
676 let mut x = 0.0;
677
678 for (item_idx, item) in items
679 .iter()
680 .enumerate()
681 .take(sol.break_item + 1)
682 .skip(item_start)
683 {
684 match item {
685 Item::Box {
686 char_start,
687 char_end,
688 ..
689 } => {
690 for ci in *char_start..*char_end {
691 if chars[ci] == '\u{00AD}' {
692 continue;
693 }
694 line_chars.push(chars[ci]);
695 line_positions.push(x);
696 x += char_widths[ci];
697 }
698 }
699 Item::Glue {
700 width,
701 stretch,
702 shrink,
703 char_index,
704 } => {
705 if item_idx == sol.break_item {
707 continue;
708 }
709 let adjusted = if apply_justify {
710 if sol.adjustment_ratio >= 0.0 {
711 width + sol.adjustment_ratio * stretch
712 } else {
713 width + sol.adjustment_ratio * shrink
714 }
715 } else {
716 *width
717 };
718 if *char_index < chars.len() {
719 line_chars.push(chars[*char_index]);
720 line_positions.push(x);
721 }
722 x += adjusted;
723 }
724 Item::Penalty { flagged, width, .. } => {
725 if item_idx == sol.break_item && *flagged {
727 line_chars.push('-');
728 line_positions.push(x);
729 x += width;
730 }
731 }
732 }
733 }
734
735 let mut effective_width = x;
737 let mut trim_idx = line_chars.len();
738 while trim_idx > 0 && line_chars[trim_idx - 1] == ' ' {
739 trim_idx -= 1;
740 if trim_idx < line_positions.len() {
741 let pos = line_positions[trim_idx];
742 effective_width = pos;
743 }
744 }
745
746 if apply_justify {
748 effective_width = effective_width.min(line_width);
749 }
750
751 lines.push(super::BrokenLine {
752 text: line_chars.iter().collect(),
753 chars: line_chars,
754 char_positions: line_positions,
755 width: effective_width,
756 });
757
758 item_start = sol.break_item + 1;
761 while item_start < items.len() && matches!(items[item_start], Item::Glue { .. }) {
762 item_start += 1;
763 }
764 }
765
766 lines
767}
768
769pub fn build_items_styled(
771 chars: &[super::StyledChar],
772 char_widths: &[f64],
773 hyphen_width: f64,
774 hyphens: crate::style::Hyphens,
775 break_opps: &[Option<BreakOpportunity>],
776 lang: Option<&str>,
777) -> Vec<Item> {
778 let plain_chars: Vec<char> = chars.iter().map(|sc| sc.ch).collect();
779 build_items(
780 &plain_chars,
781 char_widths,
782 hyphen_width,
783 hyphens,
784 break_opps,
785 lang,
786 )
787}
788
789pub fn reconstruct_run_lines(
791 solutions: &[LineSolution],
792 items: &[Item],
793 chars: &[super::StyledChar],
794 char_widths: &[f64],
795 line_width: f64,
796 justify: bool,
797) -> Vec<super::RunBrokenLine> {
798 let mut lines = Vec::new();
799 let mut item_start = 0;
800
801 for (sol_idx, sol) in solutions.iter().enumerate() {
802 let is_last_line = sol_idx == solutions.len() - 1;
803 let apply_justify = justify && !is_last_line;
804
805 let mut line_chars: Vec<super::StyledChar> = Vec::new();
806 let mut line_positions = Vec::new();
807 let mut x = 0.0;
808
809 for (item_idx, item) in items
810 .iter()
811 .enumerate()
812 .take(sol.break_item + 1)
813 .skip(item_start)
814 {
815 match item {
816 Item::Box {
817 char_start,
818 char_end,
819 ..
820 } => {
821 for ci in *char_start..*char_end {
822 if chars[ci].ch == '\u{00AD}' {
823 continue;
824 }
825 line_chars.push(chars[ci].clone());
826 line_positions.push(x);
827 x += char_widths[ci];
828 }
829 }
830 Item::Glue {
831 width,
832 stretch,
833 shrink,
834 char_index,
835 } => {
836 if item_idx == sol.break_item {
837 continue;
838 }
839 let adjusted = if apply_justify {
840 if sol.adjustment_ratio >= 0.0 {
841 width + sol.adjustment_ratio * stretch
842 } else {
843 width + sol.adjustment_ratio * shrink
844 }
845 } else {
846 *width
847 };
848 if *char_index < chars.len() {
849 line_chars.push(chars[*char_index].clone());
850 line_positions.push(x);
851 }
852 x += adjusted;
853 }
854 Item::Penalty {
855 flagged,
856 char_index,
857 width,
858 ..
859 } => {
860 if item_idx == sol.break_item && *flagged {
861 let style_ref = if *char_index > 0 {
863 &chars[char_index - 1]
864 } else {
865 &chars[0]
866 };
867 let mut hyphen_sc = style_ref.clone();
868 hyphen_sc.ch = '-';
869 line_chars.push(hyphen_sc);
870 line_positions.push(x);
871 x += width;
872 }
873 }
874 }
875 }
876
877 let mut effective_width = x;
879 let mut trim_idx = line_chars.len();
880 while trim_idx > 0 && line_chars[trim_idx - 1].ch == ' ' {
881 trim_idx -= 1;
882 if trim_idx < line_positions.len() {
883 effective_width = line_positions[trim_idx];
884 }
885 }
886
887 if apply_justify {
888 effective_width = effective_width.min(line_width);
889 }
890
891 lines.push(super::RunBrokenLine {
892 chars: line_chars,
893 char_positions: line_positions,
894 width: effective_width,
895 });
896
897 item_start = sol.break_item + 1;
898 while item_start < items.len() && matches!(items[item_start], Item::Glue { .. }) {
899 item_start += 1;
900 }
901 }
902
903 lines
904}
905
906#[cfg(test)]
907mod tests {
908 use super::*;
909
910 fn simple_items(text: &str, char_width: f64) -> (Vec<char>, Vec<f64>) {
911 let chars: Vec<char> = text.chars().collect();
912 let widths = vec![char_width; chars.len()];
913 (chars, widths)
914 }
915
916 #[test]
917 fn test_build_items_simple() {
918 let (chars, widths) = simple_items("Hello World", 10.0);
919 let break_opps = super::super::compute_break_opportunities("Hello World");
920 let items = build_items(
921 &chars,
922 &widths,
923 5.0,
924 crate::style::Hyphens::Manual,
925 &break_opps,
926 None,
927 );
928 let boxes: Vec<_> = items
930 .iter()
931 .filter(|i| matches!(i, Item::Box { .. }))
932 .collect();
933 assert_eq!(boxes.len(), 2, "Should have 2 boxes (Hello, World)");
934 }
935
936 #[test]
937 fn test_find_breaks_single_line() {
938 let (chars, widths) = simple_items("Hello World", 10.0);
939 let break_opps = super::super::compute_break_opportunities("Hello World");
940 let items = build_items(
941 &chars,
942 &widths,
943 5.0,
944 crate::style::Hyphens::Manual,
945 &break_opps,
946 None,
947 );
948 let config = Config {
949 line_width: 200.0, ..Default::default()
951 };
952 let solutions = find_breaks(&items, &config).expect("Should find solution");
953 assert_eq!(solutions.len(), 1, "Should be 1 line");
954 }
955
956 #[test]
957 fn test_find_breaks_multi_line() {
958 let (chars, widths) = simple_items("aa bb cc dd ee", 10.0);
959 let break_opps = super::super::compute_break_opportunities("aa bb cc dd ee");
960 let items = build_items(
961 &chars,
962 &widths,
963 5.0,
964 crate::style::Hyphens::Manual,
965 &break_opps,
966 None,
967 );
968 let config = Config {
969 line_width: 55.0, ..Default::default()
971 };
972 let solutions = find_breaks(&items, &config).expect("Should find solution");
973 assert!(
974 solutions.len() >= 2,
975 "Should need multiple lines, got {}",
976 solutions.len()
977 );
978 }
979
980 #[test]
981 fn test_adjustment_ratio() {
982 let (chars, widths) = simple_items("Hello World", 10.0);
983 let break_opps = super::super::compute_break_opportunities("Hello World");
984 let items = build_items(
985 &chars,
986 &widths,
987 5.0,
988 crate::style::Hyphens::Manual,
989 &break_opps,
990 None,
991 );
992 let config = Config {
993 line_width: 200.0,
994 ..Default::default()
995 };
996 let solutions = find_breaks(&items, &config).expect("Should find solution");
997 for sol in &solutions {
999 assert!(
1000 sol.adjustment_ratio.is_finite(),
1001 "Adjustment ratio should be finite"
1002 );
1003 }
1004 }
1005
1006 #[test]
1007 fn test_justify_spacing() {
1008 let (chars, widths) = simple_items("aa bb cc dd ee", 10.0);
1010 let break_opps = super::super::compute_break_opportunities("aa bb cc dd ee");
1011 let items = build_items(
1012 &chars,
1013 &widths,
1014 5.0,
1015 crate::style::Hyphens::Manual,
1016 &break_opps,
1017 None,
1018 );
1019 let config = Config {
1020 line_width: 55.0,
1021 ..Default::default()
1022 };
1023 let solutions = find_breaks(&items, &config).expect("Should find solution");
1024 let lines = reconstruct_lines(&solutions, &items, &chars, &widths, 55.0, true);
1025 assert!(lines.len() >= 2);
1026 for (i, line) in lines.iter().enumerate() {
1028 if i < lines.len() - 1 {
1029 assert!(
1030 (line.width - 55.0).abs() < 1.0,
1031 "Justified line {} width should be ~55, got {}",
1032 i,
1033 line.width
1034 );
1035 }
1036 }
1037 }
1038
1039 #[test]
1040 fn test_hyphenation_penalty() {
1041 let (chars, widths) = simple_items("extraordinary", 10.0);
1042 let break_opps = super::super::compute_break_opportunities("extraordinary");
1043 let items = build_items(
1044 &chars,
1045 &widths,
1046 5.0,
1047 crate::style::Hyphens::Auto,
1048 &break_opps,
1049 None,
1050 );
1051 let penalties: Vec<_> = items
1052 .iter()
1053 .filter(|i| matches!(i, Item::Penalty { flagged: true, .. }))
1054 .collect();
1055 assert!(
1056 !penalties.is_empty(),
1057 "Should have hyphenation penalties for 'extraordinary'"
1058 );
1059 }
1060}