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        });
766
767        // Next line starts after the break item
768        // Skip glue after the break
769        item_start = sol.break_item + 1;
770        while item_start < items.len() && matches!(items[item_start], Item::Glue { .. }) {
771            item_start += 1;
772        }
773    }
774
775    lines
776}
777
778/// Keep the letter-spacing after a line's last character from deciding
779/// whether the line fits.
780///
781/// `char_widths` carry each character's letter-spacing, the last one's
782/// included, but the spacing after the final character paints nothing and
783/// Chrome does not let it push a word to the next line. A 0.17em-tracked
784/// caption that fitted its cell once its ink was measured wrapped instead
785/// (the Northmoor certificate). The spacing moves off each box's final
786/// character: into the following glue, which a break consumes, or, at a
787/// penalty, off the penalty's width, which counts only when breaking there.
788/// Mid-line widths are unchanged; rendering still uses `char_widths`.
789pub fn hang_trailing_letter_spacing(items: &mut [Item], letter_spacing_at: impl Fn(usize) -> f64) {
790    for i in 1..items.len() {
791        let (prev, rest) = items.split_at_mut(i);
792        let Item::Box {
793            width: box_width,
794            char_end,
795            ..
796        } = &mut prev[i - 1]
797        else {
798            continue;
799        };
800        let ls = letter_spacing_at(*char_end - 1);
801        if ls == 0.0 {
802            continue;
803        }
804        // The paragraph-ending glue is followed by a forced break, so it is
805        // always on the line: drop the spacing there instead of moving it.
806        let ends_paragraph = matches!(
807            rest.get(1),
808            Some(Item::Penalty { penalty, .. }) if *penalty == f64::NEG_INFINITY
809        );
810        match &mut rest[0] {
811            Item::Glue {
812                width, char_index, ..
813            } if *char_index == *char_end => {
814                *box_width -= ls;
815                if !ends_paragraph {
816                    *width += ls;
817                }
818            }
819            Item::Penalty {
820                width,
821                flagged: false,
822                char_index,
823                ..
824            } if *char_index == *char_end => {
825                *width -= ls;
826            }
827            _ => {}
828        }
829    }
830}
831
832/// Make word spaces incompressible, for text that will not be justified.
833///
834/// Glue carries shrink so the breaker can compress a justified line, but
835/// only `reconstruct_*` with `justify` actually narrows the spaces. A
836/// ragged line is drawn at its natural width, so a break chosen by
837/// shrinking rendered past the box (the Northmoor payslip footer ran 3.9pt
838/// into the margin). Stretch stays: a ragged line may fall short.
839pub fn disallow_shrink(items: &mut [Item]) {
840    for item in items {
841        if let Item::Glue { shrink, .. } = item {
842            *shrink = 0.0;
843        }
844    }
845}
846
847/// Build items from multi-style (StyledChar) text.
848pub fn build_items_styled(
849    chars: &[super::StyledChar],
850    char_widths: &[f64],
851    hyphen_width: f64,
852    hyphens: crate::style::Hyphens,
853    break_opps: &[Option<BreakOpportunity>],
854    lang: Option<&str>,
855) -> Vec<Item> {
856    let plain_chars: Vec<char> = chars.iter().map(|sc| sc.ch).collect();
857    build_items(
858        &plain_chars,
859        char_widths,
860        hyphen_width,
861        hyphens,
862        break_opps,
863        lang,
864    )
865}
866
867/// Reconstruct run-broken lines from KP solutions, with justified spacing.
868pub fn reconstruct_run_lines(
869    solutions: &[LineSolution],
870    items: &[Item],
871    chars: &[super::StyledChar],
872    char_widths: &[f64],
873    line_width: f64,
874    justify: bool,
875) -> Vec<super::RunBrokenLine> {
876    let mut lines = Vec::new();
877    let mut item_start = 0;
878
879    for (sol_idx, sol) in solutions.iter().enumerate() {
880        let is_last_line = sol_idx == solutions.len() - 1;
881        let apply_justify = justify && !is_last_line;
882
883        let mut line_chars: Vec<super::StyledChar> = Vec::new();
884        let mut line_positions = Vec::new();
885        let mut x = 0.0;
886
887        for (item_idx, item) in items
888            .iter()
889            .enumerate()
890            .take(sol.break_item + 1)
891            .skip(item_start)
892        {
893            match item {
894                Item::Box {
895                    char_start,
896                    char_end,
897                    ..
898                } => {
899                    for ci in *char_start..*char_end {
900                        if chars[ci].ch == '\u{00AD}' {
901                            continue;
902                        }
903                        line_chars.push(chars[ci].clone());
904                        line_positions.push(x);
905                        x += char_widths[ci];
906                    }
907                }
908                Item::Glue {
909                    width,
910                    stretch,
911                    shrink,
912                    char_index,
913                } => {
914                    if item_idx == sol.break_item {
915                        continue;
916                    }
917                    // Draw the space at its own width: the glue's width may
918                    // also carry the letter-spacing hung off the previous
919                    // word (hang_trailing_letter_spacing), which that word's
920                    // characters already advance by.
921                    let natural = char_widths.get(*char_index).copied().unwrap_or(*width);
922                    let adjusted = if apply_justify {
923                        if sol.adjustment_ratio >= 0.0 {
924                            natural + sol.adjustment_ratio * stretch
925                        } else {
926                            natural + sol.adjustment_ratio * shrink
927                        }
928                    } else {
929                        natural
930                    };
931                    if *char_index < chars.len() {
932                        line_chars.push(chars[*char_index].clone());
933                        line_positions.push(x);
934                    }
935                    x += adjusted;
936                }
937                Item::Penalty {
938                    flagged,
939                    char_index,
940                    width,
941                    ..
942                } => {
943                    if item_idx == sol.break_item && *flagged {
944                        // Add hyphen with the style of the preceding char
945                        let style_ref = if *char_index > 0 {
946                            &chars[char_index - 1]
947                        } else {
948                            &chars[0]
949                        };
950                        let mut hyphen_sc = style_ref.clone();
951                        hyphen_sc.ch = '-';
952                        line_chars.push(hyphen_sc);
953                        line_positions.push(x);
954                        x += width;
955                    }
956                }
957            }
958        }
959
960        // Trim trailing spaces
961        let mut effective_width = x;
962        let mut trim_idx = line_chars.len();
963        while trim_idx > 0 && line_chars[trim_idx - 1].ch == ' ' {
964            trim_idx -= 1;
965            if trim_idx < line_positions.len() {
966                effective_width = line_positions[trim_idx];
967            }
968        }
969
970        if apply_justify {
971            effective_width = effective_width.min(line_width);
972        }
973
974        lines.push(super::RunBrokenLine {
975            chars: line_chars,
976            char_positions: line_positions,
977            width: effective_width,
978        });
979
980        item_start = sol.break_item + 1;
981        while item_start < items.len() && matches!(items[item_start], Item::Glue { .. }) {
982            item_start += 1;
983        }
984    }
985
986    lines
987}
988
989#[cfg(test)]
990mod tests {
991    use super::*;
992
993    fn simple_items(text: &str, char_width: f64) -> (Vec<char>, Vec<f64>) {
994        let chars: Vec<char> = text.chars().collect();
995        let widths = vec![char_width; chars.len()];
996        (chars, widths)
997    }
998
999    /// `Config::hyphen_penalty` was never read: every hyphen break cost the
1000    /// literal 50 its items carried, so the setting did nothing. Any finite
1001    /// penalty still allows a hyphen that no unhyphenated layout can avoid,
1002    /// so a rewarding (negative) penalty is the probe: it must hyphenate
1003    /// somewhere the default does not.
1004    #[test]
1005    fn test_config_hyphen_penalty_is_applied() {
1006        let text = "Internationalization considerations notwithstanding, comprehensive documentation remains extraordinarily indispensable.";
1007        let (chars, widths) = simple_items(text, 5.0);
1008        let break_opps = super::super::compute_break_opportunities(text);
1009        let items = build_items(
1010            &chars,
1011            &widths,
1012            5.0,
1013            crate::style::Hyphens::Auto,
1014            &break_opps,
1015            Some("en"),
1016        );
1017        let hyphenated = |line_width: f64, penalty: f64| -> usize {
1018            let config = Config {
1019                line_width,
1020                hyphen_penalty: penalty,
1021                ..Default::default()
1022            };
1023            find_breaks(&items, &config)
1024                .map(|s| s.iter().filter(|s| s.is_hyphenated).count())
1025                .unwrap_or(0)
1026        };
1027        let mut more_somewhere = false;
1028        for w in (150..=320).step_by(5) {
1029            let w = w as f64;
1030            let default = hyphenated(w, Config::default().hyphen_penalty);
1031            let rewarded = hyphenated(w, -1000.0);
1032            assert!(
1033                rewarded >= default,
1034                "width {w}: {rewarded} hyphens rewarded vs {default} by default"
1035            );
1036            more_somewhere |= rewarded > default;
1037        }
1038        assert!(
1039            more_somewhere,
1040            "hyphen_penalty changed nothing at any width: the setting is not applied"
1041        );
1042    }
1043
1044    #[test]
1045    fn test_build_items_simple() {
1046        let (chars, widths) = simple_items("Hello World", 10.0);
1047        let break_opps = super::super::compute_break_opportunities("Hello World");
1048        let items = build_items(
1049            &chars,
1050            &widths,
1051            5.0,
1052            crate::style::Hyphens::Manual,
1053            &break_opps,
1054            None,
1055        );
1056        // Should have: Box("Hello") + Glue(" ") + Box("World") + final glue + final penalty
1057        let boxes: Vec<_> = items
1058            .iter()
1059            .filter(|i| matches!(i, Item::Box { .. }))
1060            .collect();
1061        assert_eq!(boxes.len(), 2, "Should have 2 boxes (Hello, World)");
1062    }
1063
1064    #[test]
1065    fn test_find_breaks_single_line() {
1066        let (chars, widths) = simple_items("Hello World", 10.0);
1067        let break_opps = super::super::compute_break_opportunities("Hello World");
1068        let items = build_items(
1069            &chars,
1070            &widths,
1071            5.0,
1072            crate::style::Hyphens::Manual,
1073            &break_opps,
1074            None,
1075        );
1076        let config = Config {
1077            line_width: 200.0, // wide enough for everything
1078            ..Default::default()
1079        };
1080        let solutions = find_breaks(&items, &config).expect("Should find solution");
1081        assert_eq!(solutions.len(), 1, "Should be 1 line");
1082    }
1083
1084    #[test]
1085    fn test_find_breaks_multi_line() {
1086        let (chars, widths) = simple_items("aa bb cc dd ee", 10.0);
1087        let break_opps = super::super::compute_break_opportunities("aa bb cc dd ee");
1088        let items = build_items(
1089            &chars,
1090            &widths,
1091            5.0,
1092            crate::style::Hyphens::Manual,
1093            &break_opps,
1094            None,
1095        );
1096        let config = Config {
1097            line_width: 55.0, // "xx yy" = 50 wide, ratio 1.0 with 5 stretch
1098            ..Default::default()
1099        };
1100        let solutions = find_breaks(&items, &config).expect("Should find solution");
1101        assert!(
1102            solutions.len() >= 2,
1103            "Should need multiple lines, got {}",
1104            solutions.len()
1105        );
1106    }
1107
1108    #[test]
1109    fn test_adjustment_ratio() {
1110        let (chars, widths) = simple_items("Hello World", 10.0);
1111        let break_opps = super::super::compute_break_opportunities("Hello World");
1112        let items = build_items(
1113            &chars,
1114            &widths,
1115            5.0,
1116            crate::style::Hyphens::Manual,
1117            &break_opps,
1118            None,
1119        );
1120        let config = Config {
1121            line_width: 200.0,
1122            ..Default::default()
1123        };
1124        let solutions = find_breaks(&items, &config).expect("Should find solution");
1125        // Single line — last line has ratio 0 (or close)
1126        for sol in &solutions {
1127            assert!(
1128                sol.adjustment_ratio.is_finite(),
1129                "Adjustment ratio should be finite"
1130            );
1131        }
1132    }
1133
1134    #[test]
1135    fn test_justify_spacing() {
1136        // Justified non-final lines should fill to line_width
1137        let (chars, widths) = simple_items("aa bb cc dd ee", 10.0);
1138        let break_opps = super::super::compute_break_opportunities("aa bb cc dd ee");
1139        let items = build_items(
1140            &chars,
1141            &widths,
1142            5.0,
1143            crate::style::Hyphens::Manual,
1144            &break_opps,
1145            None,
1146        );
1147        let config = Config {
1148            line_width: 55.0,
1149            ..Default::default()
1150        };
1151        let solutions = find_breaks(&items, &config).expect("Should find solution");
1152        let lines = reconstruct_lines(&solutions, &items, &chars, &widths, 55.0, true);
1153        assert!(lines.len() >= 2);
1154        // Non-final lines should have width close to line_width
1155        for (i, line) in lines.iter().enumerate() {
1156            if i < lines.len() - 1 {
1157                assert!(
1158                    (line.width - 55.0).abs() < 1.0,
1159                    "Justified line {} width should be ~55, got {}",
1160                    i,
1161                    line.width
1162                );
1163            }
1164        }
1165    }
1166
1167    #[test]
1168    fn test_hyphenation_penalty() {
1169        let (chars, widths) = simple_items("extraordinary", 10.0);
1170        let break_opps = super::super::compute_break_opportunities("extraordinary");
1171        let items = build_items(
1172            &chars,
1173            &widths,
1174            5.0,
1175            crate::style::Hyphens::Auto,
1176            &break_opps,
1177            None,
1178        );
1179        let penalties: Vec<_> = items
1180            .iter()
1181            .filter(|i| matches!(i, Item::Penalty { flagged: true, .. }))
1182            .collect();
1183        assert!(
1184            !penalties.is_empty(),
1185            "Should have hyphenation penalties for 'extraordinary'"
1186        );
1187    }
1188}