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 flagged = match item {
473                    Item::Penalty { flagged, .. } => *flagged,
474                    _ => false,
475                };
476                // A hyphen break costs `config.hyphen_penalty`. The items
477                // carry the same default, but reading them alone left the
478                // setting with no effect.
479                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                // Extra demerits for consecutive hyphenated lines
495                let demerits = if flagged && a.is_hyphenated {
496                    demerits + config.double_hyphen_demerits
497                } else {
498                    demerits
499                };
500
501                // Extra demerits for fitness class mismatch
502                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            // Deactivate overfull breakpoints
518            for d in &deactivate {
519                active.retain(|x| x != d);
520            }
521
522            // Compute "after break" totals: for glue breaks, include the glue;
523            // for penalty breaks, just use current totals (penalty is zero-width in totals).
524            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            // Create new breakpoints for the best of each fitness class
539            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 no active breakpoints remain, we're in trouble — give up
566            if active.is_empty() {
567                return None;
568            }
569        }
570
571        // Update running totals AFTER processing breaks
572        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    // Find the best final breakpoint — must be at the last item (the forced
591    // paragraph-ending break). The initial dummy breakpoint at item 0 may still
592    // be active but is not a valid solution endpoint.
593    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    // Backtrack to build the solution
606    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            // This is a real break (not the initial dummy)
613            let prev_bp = &breakpoints[prev_idx];
614
615            // Compute adjustment ratio for this line.
616            // For glue breaks, bp.total_* includes the break glue (which is
617            // consumed, not on the line), so subtract it back out.
618            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
659/// Reconstruct broken lines from KP solutions, with justified spacing.
660///
661/// For justified text, spaces are stretched/shrunk according to the
662/// adjustment ratio. The last line is always left-aligned.
663pub 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                    // Skip trailing glue at the break point
710                    if item_idx == sol.break_item {
711                        continue;
712                    }
713                    // Draw the space at its own width: the glue's width may
714                    // also carry the letter-spacing hung off the previous
715                    // word (hang_trailing_letter_spacing), which that word's
716                    // characters already advance by.
717                    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 this is the break point and it's a hyphenation, add a hyphen
735                    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        // Trim trailing spaces from width
745        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        // For justified text (non-last line), width should be close to target
756        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        // Next line starts after the break item
769        // Skip glue after the break
770        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
779/// Keep the letter-spacing after a line's last character from deciding
780/// whether the line fits.
781///
782/// `char_widths` carry each character's letter-spacing, the last one's
783/// included, but the spacing after the final character paints nothing and
784/// Chrome does not let it push a word to the next line. A 0.17em-tracked
785/// caption that fitted its cell once its ink was measured wrapped instead
786/// (the Northmoor certificate). The spacing moves off each box's final
787/// character: into the following glue, which a break consumes, or, at a
788/// penalty, off the penalty's width, which counts only when breaking there.
789/// Mid-line widths are unchanged; rendering still uses `char_widths`.
790pub 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        // The paragraph-ending glue is followed by a forced break, so it is
806        // always on the line: drop the spacing there instead of moving it.
807        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
833/// Make word spaces incompressible, for text that will not be justified.
834///
835/// Glue carries shrink so the breaker can compress a justified line, but
836/// only `reconstruct_*` with `justify` actually narrows the spaces. A
837/// ragged line is drawn at its natural width, so a break chosen by
838/// shrinking rendered past the box (the Northmoor payslip footer ran 3.9pt
839/// into the margin). Stretch stays: a ragged line may fall short.
840pub 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
848/// Build items from multi-style (StyledChar) text.
849pub 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
868/// Reconstruct run-broken lines from KP solutions, with justified spacing.
869pub 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                    // Draw the space at its own width: the glue's width may
919                    // also carry the letter-spacing hung off the previous
920                    // word (hang_trailing_letter_spacing), which that word's
921                    // characters already advance by.
922                    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                        // Add hyphen with the style of the preceding char
946                        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        // Trim trailing spaces
962        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    /// `Config::hyphen_penalty` was never read: every hyphen break cost the
1002    /// literal 50 its items carried, so the setting did nothing. Any finite
1003    /// penalty still allows a hyphen that no unhyphenated layout can avoid,
1004    /// so a rewarding (negative) penalty is the probe: it must hyphenate
1005    /// somewhere the default does not.
1006    #[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        // Should have: Box("Hello") + Glue(" ") + Box("World") + final glue + final penalty
1059        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, // wide enough for everything
1080            ..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, // "xx yy" = 50 wide, ratio 1.0 with 5 stretch
1100            ..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        // Single line — last line has ratio 0 (or close)
1128        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        // Justified non-final lines should fill to line_width
1139        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        // Non-final lines should have width close to line_width
1157        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}