Skip to main content

forme/text/
knuth_plass.rs

1//! # Knuth-Plass Optimal Line Breaking
2//!
3//! Implements the Knuth-Plass algorithm for computing optimal line breaks.
4//! Instead of greedily filling each line, this considers all possible break
5//! points and minimizes a global penalty (demerits) across the entire paragraph.
6//!
7//! The result: more even word spacing, fewer rivers of whitespace, and better
8//! hyphenation decisions. Critical for justified text.
9
10use unicode_linebreak::BreakOpportunity;
11
12/// An item in the Knuth-Plass item list.
13#[derive(Debug, Clone)]
14pub enum Item {
15    /// A fixed-width content box (word fragment, character).
16    Box {
17        width: f64,
18        /// Char range [start, end) in the original char array.
19        char_start: usize,
20        char_end: usize,
21    },
22    /// Stretchable/shrinkable space (typically a word space).
23    Glue {
24        width: f64,
25        stretch: f64,
26        shrink: f64,
27        /// Char index this glue corresponds to in the original text.
28        char_index: usize,
29    },
30    /// A potential break point with a cost.
31    Penalty {
32        width: f64,
33        penalty: f64,
34        flagged: bool,
35        /// Char index where this penalty sits.
36        char_index: usize,
37    },
38}
39
40/// Configuration for the Knuth-Plass algorithm.
41#[derive(Debug, Clone)]
42pub struct Config {
43    pub line_width: f64,
44    /// How much lines are allowed to stretch/shrink. Higher = more tolerance.
45    pub tolerance: f64,
46    /// Penalty for hyphenating a word.
47    pub hyphen_penalty: f64,
48    /// Extra demerits for two consecutive hyphenated lines.
49    pub double_hyphen_demerits: f64,
50    /// Extra demerits for adjacent lines with very different tightness.
51    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/// The solution for one line: where it breaks and how much the glue adjusts.
67#[derive(Debug, Clone)]
68pub struct LineSolution {
69    /// Index into the items array where this line ends (the break item).
70    pub break_item: usize,
71    /// How much glue was stretched (positive) or shrunk (negative).
72    /// Range roughly -1.0 to tolerance.
73    pub adjustment_ratio: f64,
74    /// Whether this line ends with a hyphen.
75    pub is_hyphenated: bool,
76}
77
78/// Fitness class for the Knuth-Plass algorithm.
79#[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/// An active breakpoint in the DP.
100#[derive(Debug, Clone)]
101struct Breakpoint {
102    /// Index in the items array where this break occurs.
103    item_index: usize,
104    /// Line number (0-based) ending at this break.
105    line: usize,
106    /// Fitness class of the line ending at this break.
107    fitness: FitnessClass,
108    /// Total width of items from the start of the paragraph to this break.
109    total_width: f64,
110    /// Total stretch available from the start to this break.
111    total_stretch: f64,
112    /// Total shrink available from the start to this break.
113    total_shrink: f64,
114    /// Total demerits accumulated up to this break.
115    total_demerits: f64,
116    /// Index of the previous breakpoint in the chain (for backtracking).
117    prev: Option<usize>,
118    /// Whether this break was a hyphenation.
119    is_hyphenated: bool,
120}
121
122/// Build items from a plain-text char array with pre-computed widths.
123///
124/// This converts the character-level data into the box/glue/penalty sequence
125/// that the Knuth-Plass algorithm operates on.
126pub 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        // Estimate space width from char_widths — find the first space
139        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        // Skip soft hyphens (zero-width when not breaking)
154        if ch == '\u{00AD}' {
155            // Soft hyphen: emit current box, add penalty for possible break
156            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        // Skip newline/CR (handled as mandatory breaks)
178        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            // Mandatory break: penalty of -infinity
188            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        // Check for UAX#14 break opportunity *before* this char
200        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                        // If the previous char was a space, we already emitted glue
222                        // Only add a zero-width penalty for non-space allowed breaks
223                        // (e.g., CJK inter-character breaks, after hyphens)
224                        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        // Space → emit box + glue
248        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        // Regular character → accumulate into current box
270        box_width += char_widths[i];
271        i += 1;
272    }
273
274    // Flush trailing box
275    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    // Add hyphenation penalties if hyphens == Auto
284    if hyphens == crate::style::Hyphens::Auto {
285        items = insert_hyphenation_penalties(items, chars, char_widths, hyphen_width, lang);
286    }
287
288    // Final forced break (the paragraph must end)
289    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
305/// Insert hyphenation penalties into the item list at syllable boundaries.
306fn 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                // Only hyphenate boxes that are actual words (alpha chars)
330                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                // Split this box into sub-boxes with penalty breaks between them
346                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
379/// Find optimal break points using the Knuth-Plass algorithm.
380///
381/// Returns `None` if no feasible solution exists (text can't fit at all).
382pub 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    // Start with a breakpoint at the beginning
387    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    // Running totals up to current position
401    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        // Check if this is a feasible break point BEFORE updating totals for glue.
407        // For glue breaks, the break is BEFORE the glue (glue is consumed).
408        // For penalty breaks, break is AT the penalty.
409        let is_break = match item {
410            Item::Penalty { penalty, .. } => *penalty < f64::INFINITY,
411            Item::Glue { .. } => {
412                // Can break before glue if preceded by a box
413                i > 0 && matches!(items[i - 1], Item::Box { .. })
414            }
415            _ => false,
416        };
417
418        if is_break {
419            // For each active breakpoint, compute the adjustment ratio
420            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                // Width of items on this line (from break a to break i).
429                // total_width at this point does NOT include the current item yet.
430                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                // Add penalty width if breaking at a penalty
435                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                    // Need to stretch
445                    if line_stretch > 0.0 {
446                        (target - actual_width) / line_stretch
447                    } else {
448                        f64::INFINITY
449                    }
450                } else if actual_width > target {
451                    // Need to shrink
452                    if line_shrink > 0.0 {
453                        (target - actual_width) / line_shrink
454                    } else {
455                        f64::INFINITY
456                    }
457                } else {
458                    0.0 // Perfect fit
459                };
460
461                // Check if this break is feasible
462                if ratio < -1.0 || ratio > config.tolerance {
463                    // If ratio < -1, we've compressed as much as possible and it still overflows.
464                    // If this is because ratio < -1 (overfull), deactivate this breakpoint.
465                    if ratio < -1.0 {
466                        deactivate.push(a_idx);
467                    }
468                    continue;
469                }
470
471                // Compute demerits for this break
472                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                // Extra demerits for consecutive hyphenated lines
491                let demerits = if flagged && a.is_hyphenated {
492                    demerits + config.double_hyphen_demerits
493                } else {
494                    demerits
495                };
496
497                // Extra demerits for fitness class mismatch
498                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            // Deactivate overfull breakpoints
514            for d in &deactivate {
515                active.retain(|x| x != d);
516            }
517
518            // Compute "after break" totals: for glue breaks, include the glue;
519            // for penalty breaks, just use current totals (penalty is zero-width in totals).
520            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            // Create new breakpoints for the best of each fitness class
535            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 no active breakpoints remain, we're in trouble — give up
562            if active.is_empty() {
563                return None;
564            }
565        }
566
567        // Update running totals AFTER processing breaks
568        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    // Find the best final breakpoint — must be at the last item (the forced
587    // paragraph-ending break). The initial dummy breakpoint at item 0 may still
588    // be active but is not a valid solution endpoint.
589    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    // Backtrack to build the solution
602    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            // This is a real break (not the initial dummy)
609            let prev_bp = &breakpoints[prev_idx];
610
611            // Compute adjustment ratio for this line.
612            // For glue breaks, bp.total_* includes the break glue (which is
613            // consumed, not on the line), so subtract it back out.
614            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
655/// Reconstruct broken lines from KP solutions, with justified spacing.
656///
657/// For justified text, spaces are stretched/shrunk according to the
658/// adjustment ratio. The last line is always left-aligned.
659pub 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                    // Skip trailing glue at the break point
706                    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 this is the break point and it's a hyphenation, add a hyphen
726                    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        // Trim trailing spaces from width
736        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        // For justified text (non-last line), width should be close to target
747        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        // Next line starts after the break item
759        // Skip glue after the break
760        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
769/// Build items from multi-style (StyledChar) text.
770pub 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
789/// Reconstruct run-broken lines from KP solutions, with justified spacing.
790pub 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                        // Add hyphen with the style of the preceding char
862                        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        // Trim trailing spaces
878        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        // Should have: Box("Hello") + Glue(" ") + Box("World") + final glue + final penalty
929        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, // wide enough for everything
950            ..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, // "xx yy" = 50 wide, ratio 1.0 with 5 stretch
970            ..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        // Single line — last line has ratio 0 (or close)
998        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        // Justified non-final lines should fill to line_width
1009        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        // Non-final lines should have width close to line_width
1027        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}