1
//! An implementation of the Knuth-Plass line-breaking algorithm
2
//! for simple rectangular layouts.
3

            
4
#[cfg(feature = "text_layout_hyphenation")]
5
use hyphenation::{Hyphenator, Standard};
6
#[cfg(not(feature = "text_layout_hyphenation"))]
7
use crate::text3::cache::Standard;
8

            
9
use crate::text3::cache::{
10
    get_base_direction_from_logical, get_item_measure, is_no_break_space, is_word_separator,
11
    is_zero_width_space,
12
    AvailableSpace, BidiDirection, JustifyContent, LayoutError, LoadedFonts,
13
    LogicalItem, OverflowInfo, ParsedFontTrait, Point, PositionedItem,
14
    ShapedItem, TextAlign, UnifiedConstraints, UnifiedLayout,
15
};
16

            
17
const INFINITY_BADNESS: f32 = 10000.0;
18
const SPACE_STRETCH_RATIO: f32 = 0.5;
19
const SPACE_SHRINK_RATIO: f32 = 0.33;
20
const HYPHENATION_PENALTY: f32 = 50.0;
21
const BADNESS_MULTIPLIER: f32 = 100.0;
22

            
23
/// Represents the elements of a paragraph for the line-breaking algorithm.
24
#[derive(Debug, Clone)]
25
enum LayoutNode {
26
    /// A non-stretchable, non-shrinkable item (a glyph cluster or an object).
27
    Box(ShapedItem, f32), // Item and its width
28
    /// A flexible space.
29
    Glue {
30
        item: ShapedItem,
31
        /// Natural width of the space.
32
        width: f32,
33
        /// Maximum amount the space can grow beyond its natural width.
34
        stretch: f32,
35
        /// Maximum amount the space can shrink below its natural width.
36
        shrink: f32,
37
    },
38
    /// A point where a line break is allowed, with an associated cost.
39
    Penalty {
40
        /// Optional item associated with the penalty (e.g., a hyphen glyph).
41
        item: Option<ShapedItem>,
42
        width: f32,
43
        penalty: f32,
44
    },
45
}
46

            
47
/// Stores the result of the dynamic programming algorithm for a given point.
48
#[derive(Debug, Clone, Copy)]
49
struct Breakpoint {
50
    /// The total demerit score to reach this point.
51
    demerit: f32,
52
    /// The index of the previous breakpoint in the optimal path.
53
    previous: usize,
54
    /// The line number this breakpoint ends.
55
    line: usize,
56
}
57

            
58
/// Main entry point for the Knuth-Plass layout algorithm.
59
///
60
/// This implements optimal line-breaking as described in "Breaking Paragraphs into Lines"
61
/// (Knuth & Plass, 1981). Unlike greedy algorithms, it considers the entire paragraph
62
/// to find globally optimal break points.
63
///
64
/// # Use Cases
65
///
66
/// - `text-wrap: balance` - CSS property for balanced line lengths
67
/// - High-quality typesetting where line consistency matters
68
/// - Multi-line headings that should appear visually balanced
69
///
70
/// # Limitations
71
///
72
/// - Only supports horizontal text (vertical writing modes use greedy algorithm)
73
/// - Higher computational cost than greedy breaking
74
/// - May produce different results than browsers for edge cases
75
/// - overflow-wrap: anywhere/break-word emergency breaks stay greedy-only
76
///   (word-break / hyphens-driven opportunities ARE honoured here)
77
// overflow-wrap emergency breaks; the greedy break_one_line path handles this
78
84
pub(crate) fn kp_layout<T: ParsedFontTrait>(
79
84
    items: &[ShapedItem],
80
84
    logical_items: &[LogicalItem],
81
84
    constraints: &UnifiedConstraints,
82
84
    hyphenator: Option<&Standard>,
83
84
    fonts: &LoadedFonts<T>,
84
84
) -> UnifiedLayout {
85
84
    if items.is_empty() {
86
7
        return UnifiedLayout {
87
7
            items: Vec::new(),
88
7
            overflow: OverflowInfo::default(),
89
7
        };
90
77
    }
91

            
92
    // Convert ShapedItems into a sequence of Boxes, Glue, and Penalties.
93
    // The paragraph base direction gates hyphenation (see 508895 below);
94
    // the constraints carry word-break/line-break/overflow-wrap so the KP
95
    // path honours the same soft-wrap controls as the greedy path.
96
77
    let base_direction = get_base_direction_from_logical(logical_items);
97
77
    let nodes = convert_items_to_nodes(items, hyphenator, fonts, constraints, base_direction);
98

            
99
    // Dynamic Programming to find optimal breakpoints
100
77
    let breaks = find_optimal_breakpoints(&nodes, constraints);
101

            
102
    // Use breakpoints to build and position the final lines
103
77
    let final_layout: UnifiedLayout =
104
77
        position_lines_from_breaks(&nodes, &breaks, logical_items, constraints);
105

            
106
77
    final_layout
107
84
}
108

            
109
/// Converts a slice of `ShapedItems` into the Box/Glue/Penalty model.
110
// +spec:line-breaking:16e64c - soft wrap opportunity controls (word-break, overflow-wrap, line-break) threaded via UnifiedConstraints
111
#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
112
120
fn convert_items_to_nodes<T: ParsedFontTrait>(
113
120
    items: &[ShapedItem],
114
120
    hyphenator: Option<&Standard>,
115
120
    fonts: &LoadedFonts<T>,
116
120
    constraints: &UnifiedConstraints,
117
120
    base_direction: BidiDirection,
118
120
) -> Vec<LayoutNode> {
119
120
    let mut nodes = Vec::new();
120
120
    let is_vertical = false; // Knuth-Plass is horizontal-only for now
121
120
    let mut item_iter = items.iter().peekable();
122

            
123
1417
    while let Some(item) = item_iter.next() {
124
        // +spec:line-breaking:f12241 - shaping across intra-word breaks: shaped clusters preserve joining forms
125
454
        match item {
126
1297
            item if is_zero_width_space(item) => {
127
407
                nodes.push(LayoutNode::Penalty {
128
407
                    item: None,
129
407
                    width: 0.0,
130
407
                    penalty: 0.0,
131
407
                });
132
407
            }
133
890
            item if is_word_separator(item) => {
134
384
                let width = get_item_measure(item, is_vertical);
135
384
                nodes.push(LayoutNode::Glue {
136
384
                    item: item.clone(),
137
384
                    width,
138
384
                    stretch: width * SPACE_STRETCH_RATIO,
139
384
                    shrink: width * SPACE_SHRINK_RATIO,
140
384
                });
141
                // NBSP & friends (UAX#14 class GL/WJ) add word-spacing Glue but are NOT
142
                // soft-wrap opportunities, so suppress the break Penalty for them —
143
                // otherwise "1\u{00A0}km" could wrap between the number and unit.
144
384
                if !is_no_break_space(item) {
145
382
                    nodes.push(LayoutNode::Penalty {
146
382
                        item: None,
147
382
                        width: 0.0,
148
382
                        penalty: 0.0,
149
382
                    });
150
382
                }
151
            }
152
454
            ShapedItem::Cluster(cluster)
153
454
                if cluster.text().ends_with('\u{002D}')
154
440
                    || cluster.text().ends_with('\u{2010}') =>
155
15
            {
156
15
                let width = get_item_measure(item, is_vertical);
157
15
                nodes.push(LayoutNode::Box(item.clone(), width));
158
15
                // +spec:line-breaking:2d3674 - U+002D/U+2010 are soft wrap opportunities, not hyphenation opportunities (no extra glyph inserted)
159
15
                // Zero-width penalty: allows a line break after the visible
160
15
                // hyphen character without inserting an additional hyphen glyph.
161
15
                nodes.push(LayoutNode::Penalty {
162
15
                    item: None,
163
15
                    width: 0.0,
164
15
                    penalty: 0.0,
165
15
                });
166
15
            }
167
439
            ShapedItem::Cluster(cluster) => {
168
                // 1. Collect all adjacent clusters to form a full "word".
169
439
                let mut current_word_clusters = vec![cluster.clone()];
170
1210
                while let Some(peeked_item) = item_iter.peek() {
171
1150
                    if let ShapedItem::Cluster(next_cluster) = peeked_item {
172
                        // Stop collecting *before* any soft-wrap boundary so the outer
173
                        // loop can emit the correct node for it. Word separators are
174
                        // shaped as ordinary Clusters (text " "), so without these guards
175
                        // the greedy collection would absorb every space into one giant
176
                        // "word" and the paragraph could never break — it would collapse
177
                        // onto a single line. Boundaries handled by the outer loop:
178
                        //   * word separator     -> Glue + Penalty (soft wrap)
179
                        //   * zero-width space    -> Penalty (soft wrap)
180
                        //   * cluster ending '-'  -> Box + zero-width Penalty
181
                        //     (+spec:line-breaking:2d3674 — U+002D/U+2010 are UAX#14
182
                        //     class BA break opportunities AFTER the hyphen; a hyphen
183
                        //     occurs mid-word, e.g. "well-being").
184
1141
                        if is_word_separator(peeked_item)
185
781
                            || is_zero_width_space(peeked_item)
186
780
                            || next_cluster.text().ends_with('\u{002D}')
187
772
                            || next_cluster.text().ends_with('\u{2010}')
188
                        {
189
370
                            break;
190
771
                        }
191
771
                        current_word_clusters.push(next_cluster.clone());
192
771
                        item_iter.next(); // Consume the peeked item
193
                    } else {
194
                        // Stop if we hit a non-cluster item (object, tab, break, etc.)
195
9
                        break;
196
                    }
197
                }
198

            
199
                // +spec:line-breaking:28a40b - Hyphenation is a rendering-only effect (no change to underlying content)
200
                // +spec:line-breaking:f23fe8 - UA may use language-tailored heuristics (delegated to hyphenation crate)
201
                // 2. Try to find all hyphenation opportunities for this word.
202
                // +spec:display-property:508895 - hyphenation of direction-mismatched words is suppressed
203
                // (CSS 2.2 §9.10 note: hyphenating an LTR word inside an RTL
204
                // paragraph - or vice versa - would render the hyphen visually
205
                // MID-line rather than at the line edge, so UAs usually
206
                // suppress it. The word's direction comes from its first
207
                // cluster; the paragraph's from the logical items.)
208
439
                let word_direction = current_word_clusters
209
439
                    .first()
210
439
                    .map_or(base_direction, |c| c.direction);
211
439
                let hyphenation_allowed = word_direction == base_direction;
212
439
                let hyphenation_breaks = hyphenator.filter(|_| hyphenation_allowed).and_then(|h| {
213
                    crate::text3::cache::find_all_hyphenation_breaks(
214
                        &current_word_clusters,
215
                        h,
216
                        is_vertical,
217
                        fonts,
218
                    )
219
                });
220

            
221
439
                if hyphenation_breaks.is_none() {
222
                    // No hyphenation possible, add the whole word as boxes -
223
                    // with the SAME soft-wrap opportunities between clusters
224
                    // the greedy path grants (word-break: break-all breaks
225
                    // between any two clusters, `normal` between CJK ones,
226
                    // keep-all suppresses those; the shared predicate is the
227
                    // single source of truth). Without this, `text-wrap:
228
                    // balance` (the KP path) silently ignored word-break and
229
                    // CJK runs never gained intra-word breaks.
230
439
                    let n_word = current_word_clusters.len();
231
1210
                    for (ci, c) in current_word_clusters.iter().enumerate() {
232
1210
                        nodes.push(LayoutNode::Box(ShapedItem::Cluster(c.clone()), c.advance));
233
1210
                        let is_last = ci + 1 == n_word;
234
1210
                        if !is_last
235
771
                            && crate::text3::cache::is_break_opportunity_with_word_break(
236
771
                                &ShapedItem::Cluster(c.clone()),
237
771
                                constraints.word_break,
238
771
                                constraints.hyphenation,
239
                            )
240
5
                        {
241
5
                            nodes.push(LayoutNode::Penalty {
242
5
                                item: None,
243
5
                                width: 0.0,
244
5
                                penalty: 0.0,
245
5
                            });
246
1205
                        }
247
                    }
248
                } else {
249
                    // 3. Convert word + hyphenation breaks into a sequence of Boxes and Penalties.
250
                    let breaks = hyphenation_breaks.unwrap();
251
                    let mut current_item_cursor = 0;
252

            
253
                    for b in &breaks {
254
                        // Add the items that form the next syllable (the part between the last
255
                        // break and this one)
256
                        let num_items_in_syllable = b.line_part.len() - current_item_cursor;
257
                        for item in b.line_part.iter().skip(current_item_cursor) {
258
                            nodes.push(LayoutNode::Box(
259
                                item.clone(),
260
                                get_item_measure(item, is_vertical),
261
                            ));
262
                        }
263
                        current_item_cursor += num_items_in_syllable;
264

            
265
                        let hyphen_measure = get_item_measure(&b.hyphen_item, is_vertical);
266
                        nodes.push(LayoutNode::Penalty {
267
                            item: Some(b.hyphen_item.clone()),
268
                            width: hyphen_measure,
269
                            penalty: HYPHENATION_PENALTY, // Standard penalty for hyphenation
270
                        });
271
                    }
272

            
273
                    // Add the final remainder of the word.
274
                    if let Some(last_break) = breaks.last() {
275
                        for remainder_item in &last_break.remainder_part {
276
                            nodes.push(LayoutNode::Box(
277
                                remainder_item.clone(),
278
                                get_item_measure(remainder_item, is_vertical),
279
                            ));
280
                        }
281
                    } else {
282
                        // This case happens if find_all_hyphenation_breaks returned an empty vec.
283
                        // Fallback to just adding the original word.
284
                        for c in current_word_clusters {
285
                            nodes.push(LayoutNode::Box(ShapedItem::Cluster(c.clone()), c.advance));
286
                        }
287
                    }
288
                }
289
            }
290
            // Per CSS Text 3 §5.1: "there is a soft wrap opportunity before and
291
            // after each replaced element or other atomic inline"
292
19
            ShapedItem::Object { .. } | ShapedItem::CombinedBlock { .. } => {
293
19
                // Soft wrap opportunity before the atomic inline
294
19
                nodes.push(LayoutNode::Penalty {
295
19
                    item: None,
296
19
                    width: 0.0,
297
19
                    penalty: 0.0,
298
19
                });
299
19
                nodes.push(LayoutNode::Box(
300
19
                    item.clone(),
301
19
                    get_item_measure(item, is_vertical),
302
19
                ));
303
19
                // Soft wrap opportunity after the atomic inline
304
19
                nodes.push(LayoutNode::Penalty {
305
19
                    item: None,
306
19
                    width: 0.0,
307
19
                    penalty: 0.0,
308
19
                });
309
19
            }
310
19
            ShapedItem::Tab { bounds, .. } => {
311
19
                nodes.push(LayoutNode::Glue {
312
19
                    item: item.clone(),
313
19
                    width: bounds.width,
314
19
                    stretch: bounds.width * SPACE_STRETCH_RATIO, // Treat like a space for flexibility
315
19
                    shrink: bounds.width * SPACE_SHRINK_RATIO,
316
19
                });
317
19
            }
318
14
            ShapedItem::Break { .. } => {
319
14
                nodes.push(LayoutNode::Penalty {
320
14
                    item: None,
321
14
                    width: 0.0,
322
14
                    penalty: -INFINITY_BADNESS,
323
14
                });
324
14
            }
325
        }
326
    }
327

            
328
    // Anchor the end of the paragraph. Standard Knuth-Plass appends a finishing
329
    // forced break so the final line is broken at the paragraph end. Without it,
330
    // a paragraph that ends in an ordinary word (its final nodes are Box, not a
331
    // Penalty) never sets breakpoints[n] in the DP, so the backtrack collapses
332
    // the entire paragraph onto a single line. A forced break at the end makes
333
    // n a legal breakpoint regardless of the last node's type; the forced-break
334
    // scoring in find_optimal_breakpoints already exempts a short last line from
335
    // any badness, so no finishing glue is required. The penalty carries no item,
336
    // so it contributes nothing to the positioned output.
337
120
    if !nodes.is_empty()
338
7
        && !matches!(
339
119
            nodes.last(),
340
53
            Some(LayoutNode::Penalty { penalty, .. }) if *penalty <= -INFINITY_BADNESS
341
        )
342
112
    {
343
112
        nodes.push(LayoutNode::Penalty {
344
112
            item: None,
345
112
            width: 0.0,
346
112
            penalty: -INFINITY_BADNESS,
347
112
        });
348
112
    }
349

            
350
120
    nodes
351
120
}
352

            
353
/// Uses dynamic programming to find the optimal set of line breaks.
354
#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
355
#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
356
#[allow(clippy::cognitive_complexity)] // cohesive Knuth-Plass DP: one branch per break class
357
133
fn find_optimal_breakpoints(nodes: &[LayoutNode], constraints: &UnifiedConstraints) -> Vec<usize> {
358
    // For MinContent (intrinsic min-content sizing), CSS wants the width of the
359
    // widest unbreakable unit (word). Break at EVERY legal opportunity so each
360
    // word lands on its own line; the widest resulting line then equals the
361
    // widest word. The optimizing DP below cannot express this: with an
362
    // effectively infinite width it puts the whole paragraph on one line, making
363
    // min-content == max-content. Every Penalty node is a legal break point.
364
133
    if matches!(constraints.available_width, AvailableSpace::MinContent) {
365
16
        let mut breaks = Vec::new();
366
86
        for (i, node) in nodes.iter().enumerate() {
367
86
            if matches!(node, LayoutNode::Penalty { .. }) {
368
36
                breaks.push(i + 1);
369
50
            }
370
        }
371
        // Ensure the final segment (which may end in Box nodes) forms a line.
372
16
        if breaks.last() != Some(&nodes.len()) {
373
1
            breaks.push(nodes.len());
374
15
        }
375
16
        return breaks;
376
117
    }
377

            
378
    // For Knuth-Plass, we need a definite line width.
379
    //
380
    // For MaxContent, use a very large value (no line breaks unless forced).
381
    // The actual min-content width is determined by the widest resulting line.
382

            
383
117
    let line_width = match constraints.available_width {
384
101
        AvailableSpace::Definite(w) => w,
385
16
        AvailableSpace::MaxContent => f32::MAX / 2.0,
386
        // MinContent is handled by the early return above; keep a large width as
387
        // a defensive fallback so this arm never breaks after every character.
388
        AvailableSpace::MinContent => f32::MAX / 2.0,
389
    };
390

            
391
    // (and lines after forced breaks when each-line is set). The hanging keyword would
392
    // invert this, indenting all lines EXCEPT the first.
393
117
    let text_indent = constraints.text_indent;
394
117
    let first_line_width = if constraints.text_indent_hanging {
395
6
        line_width
396
    } else {
397
111
        line_width - text_indent
398
    };
399
117
    let non_first_line_width = if constraints.text_indent_hanging {
400
6
        line_width - text_indent
401
    } else {
402
111
        line_width
403
    };
404

            
405
    // Prefix sums for O(1) range queries (eliminates O(n³) inner loop).
406
    //
407
    // Knuth-Plass counts a Penalty's width ONLY when the line breaks AT that
408
    // penalty (the hyphen is rendered at the split, nowhere else - CSS Text 3
409
    // §5.3). Interior hyphenation opportunities therefore contribute ZERO to
410
    // a candidate line's width: keep penalties out of `prefix_width` and add
411
    // the break-node's own width explicitly in the DP loop. The old sums
412
    // inflated every candidate by one hyphen width per unused opportunity,
413
    // and the positioner then rendered a "-" at every opportunity mid-line.
414
117
    let n = nodes.len();
415
117
    let mut prefix_width = vec![0.0f32; n + 1];
416
117
    let mut prefix_stretch = vec![0.0f32; n + 1];
417
117
    let mut prefix_shrink = vec![0.0f32; n + 1];
418
2737
    for (k, node) in nodes.iter().enumerate() {
419
2737
        let (w, st, sh) = match node {
420
1340
            LayoutNode::Box(_, w) => (*w, 0.0, 0.0),
421
            LayoutNode::Glue {
422
421
                width,
423
421
                stretch,
424
421
                shrink,
425
                ..
426
421
            } => (*width, *stretch, *shrink),
427
976
            LayoutNode::Penalty { .. } => (0.0, 0.0, 0.0),
428
        };
429
2737
        prefix_width[k + 1] = prefix_width[k] + w;
430
2737
        prefix_stretch[k + 1] = prefix_stretch[k] + st;
431
2737
        prefix_shrink[k + 1] = prefix_shrink[k] + sh;
432
    }
433

            
434
117
    let mut breakpoints = vec![
435
117
        Breakpoint {
436
117
            // TRUE infinity for "not yet reached", NOT the finite INFINITY_BADNESS
437
117
            // (10_000). Demerits accumulate across lines, so once the optimal path's
438
117
            // cumulative demerit passed 10_000 no later candidate could beat a finite
439
117
            // sentinel, and the whole paragraph collapsed onto one overfull line.
440
117
            demerit: f32::INFINITY,
441
117
            previous: 0,
442
117
            line: 0
443
117
        };
444
117
        n + 1
445
    ];
446
117
    breakpoints[0] = Breakpoint {
447
117
        demerit: 0.0,
448
117
        previous: 0,
449
117
        line: 0,
450
117
    };
451

            
452
2737
    for i in 0..n {
453
        // Optimization:
454
        //
455
        // A legal line break can only occur at a Penalty node. If the current node
456
        // is a Box or Glue, we can skip it as a potential breakpoint.
457

            
458
2737
        if !matches!(nodes.get(i), Some(LayoutNode::Penalty { .. })) {
459
1761
            continue;
460
976
        }
461

            
462
        // Width the hyphen (or other penalty item) adds IF the line breaks here.
463
976
        let break_penalty_width = match nodes.get(i) {
464
976
            Some(LayoutNode::Penalty { width, .. }) => *width,
465
            _ => 0.0,
466
        };
467

            
468
309503
        for j in (0..=i).rev() {
469
            // Calculate the properties of a potential line from node `j` to `i`.
470
            // O(1) range sum via prefix sums: sum of nodes[j..=i], plus the
471
            // width of the penalty broken at (see the prefix-sum note above).
472
309503
            let current_width = prefix_width[i + 1] - prefix_width[j] + break_penalty_width;
473
309503
            let stretch = prefix_stretch[i + 1] - prefix_stretch[j];
474
309503
            let shrink = prefix_shrink[i + 1] - prefix_shrink[j];
475

            
476
309503
            let effective_line_width = if breakpoints[j].line == 0 {
477
183988
                first_line_width
478
125515
            } else if constraints.text_indent_hanging {
479
18
                non_first_line_width
480
            } else {
481
125497
                line_width
482
            };
483

            
484
            // Calculate adjustment ratio. If the line is wider than the available width
485
            // but has no glue to shrink, it is an invalid candidate.
486
309503
            let ratio = if current_width < effective_line_width {
487
85339
                if stretch > 0.0 {
488
4046
                    (effective_line_width - current_width) / stretch
489
                } else {
490
81293
                    INFINITY_BADNESS // Cannot stretch
491
                }
492
224164
            } else if current_width > effective_line_width {
493
223891
                if shrink > 0.0 {
494
223745
                    (effective_line_width - current_width) / shrink
495
                } else {
496
                    // Overfull with nothing to shrink: this line physically cannot
497
                    // fit, so it is INFEASIBLE — not merely "loose". Marking it with a
498
                    // large negative ratio makes the `ratio < -1.0` guard below reject
499
                    // it, exactly like an over-shrunk line. Using +INFINITY_BADNESS
500
                    // here (a positive ratio) was a bug: it let an overflowing line
501
                    // survive the feasibility guard and then be rewarded by the forced
502
                    // end-of-paragraph break, so the DP preferred one overflowing line
503
                    // over a legal break (e.g. after a mid-word hyphen). Overlong
504
                    // unbreakable content is intentionally left to the greedy path.
505
146
                    -INFINITY_BADNESS
506
                }
507
            } else {
508
273
                0.0 // Perfect fit
509
            };
510

            
511
            // Lines that must shrink too much (or that overflow with no shrink) are
512
            // invalid and cannot start an optimal path.
513
309503
            if ratio < -1.0 {
514
223882
                continue;
515
85621
            }
516

            
517
            // Calculate badness
518
85621
            let mut badness = BADNESS_MULTIPLIER * ratio.abs().powi(3);
519

            
520
            // Add penalty for the break point
521
85621
            if let Some(LayoutNode::Penalty { penalty, .. }) = nodes.get(i) {
522
85621
                if *penalty >= 0.0 {
523
84656
                    badness += penalty;
524
84656
                } else if *penalty <= -INFINITY_BADNESS {
525
965
                    badness = -INFINITY_BADNESS; // Forced break
526
965
                }
527
            }
528

            
529
            // TODO: Add demerits for consecutive lines with very different
530
            // ratios (fitness classes).
531
            //
532
            // For now, demerit is simply the cumulative badness.
533
85621
            let demerit = badness + breakpoints[j].demerit;
534

            
535
85621
            if demerit < breakpoints[i + 1].demerit {
536
1487
                breakpoints[i + 1] = Breakpoint {
537
1487
                    demerit,
538
1487
                    previous: j,
539
1487
                    line: breakpoints[j].line + 1,
540
1487
                };
541
84134
            }
542
        }
543
    }
544

            
545
    // Backtrack from the end to find the break points
546
117
    let mut breaks = Vec::new();
547
117
    let mut current = nodes.len();
548
441
    while current > 0 {
549
324
        breaks.push(current);
550
324
        let prev_idx = breakpoints[current].previous;
551
324
        current = prev_idx;
552
324
    }
553
117
    breaks.reverse();
554
117
    breaks
555
133
}
556

            
557
/// Takes the optimal break points and performs the final positioning.
558
#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
559
#[allow(clippy::cast_precision_loss)] // bounded graphics/coord/counter/fixed-point cast
560
#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
561
#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
562
127
fn position_lines_from_breaks(
563
127
    nodes: &[LayoutNode],
564
127
    breaks: &[usize],
565
127
    logical_items: &[LogicalItem],
566
127
    constraints: &UnifiedConstraints,
567
127
) -> UnifiedLayout {
568
127
    let mut positioned_items = Vec::new();
569
127
    let mut start_node = 0;
570
127
    let mut cross_axis_pen = 0.0;
571
127
    let base_direction = get_base_direction_from_logical(logical_items);
572
349
    for (line_index, &end_node) in breaks.iter().enumerate() {
573
349
        let line_nodes = &nodes[start_node..end_node];
574
349
        let is_last_line = line_index == breaks.len() - 1;
575

            
576
        // A Penalty's item (the synthesized hyphen) renders ONLY when the
577
        // line actually breaks at that penalty - i.e. for the LAST node of
578
        // the span. Cloning every penalty's item drew a "-" at each interior
579
        // hyphenation opportunity (CSS Text 3 §5.3: hyphenation visually
580
        // indicates the split, at the split).
581
349
        let last_idx = line_nodes.len().saturating_sub(1);
582
349
        let mut line_items: Vec<ShapedItem> = line_nodes
583
349
            .iter()
584
349
            .enumerate()
585
597
            .filter_map(|(k, node)| match node {
586
1339
                LayoutNode::Box(item, _) => Some(item.clone()),
587
429
                LayoutNode::Glue { item, .. } => Some(item.clone()),
588
597
                LayoutNode::Penalty { item, .. } if k == last_idx => item.clone(),
589
250
                LayoutNode::Penalty { .. } => None,
590
2365
            })
591
349
            .collect();
592

            
593
        // +spec CSS Text 3 §4.1.2: a line's trailing (line-terminating) spaces
594
        // "hang" — they are removed before measuring the line and are not counted
595
        // as justification opportunities. The break index sits just past the
596
        // Penalty following the trailing Glue, so that space is the last item
597
        // here. Drop trailing word separators so line_width, the justification
598
        // space count, and positioning all exclude them (matching the greedy
599
        // break_one_line path, which trims trailing spaces).
600
553
        while line_items.last().is_some_and(is_word_separator) {
601
204
            line_items.pop();
602
204
        }
603

            
604
        // Note: Calculate spacing, do not mutate items
605
349
        let mut extra_per_space = 0.0;
606
1564
        let line_width: f32 = line_items.iter().map(|i| get_item_measure(i, false)).sum();
607

            
608
        // the last line and lines ending with a forced break
609
349
        let ends_with_forced_break = line_nodes.iter().any(|n| matches!(
610
596
            n, LayoutNode::Penalty { penalty, .. } if *penalty <= -INFINITY_BADNESS
611
        ));
612
349
        let effective_align = super::cache::resolve_effective_alignment(
613
349
            constraints.text_align,
614
349
            constraints.text_align_last,
615
349
            is_last_line || ends_with_forced_break,
616
        );
617

            
618
        // +spec:display-contents:858337 - text-align justification: last line start-aligned, justify-all forces last line justify
619
        // +spec:display-property:50e074 - justify stretches spaces/words in inline boxes, not inline-table/inline-block
620
        // +spec:display-property:ce8d54 - text-justify selects justification method, inherited from block containers to root inline box
621
        // Justify ONLY when the RESOLVED alignment says so.
622
        // `resolve_effective_alignment` already encodes the last-line and
623
        // forced-break rules (§6.1/§6.3: Justify degrades to Start there,
624
        // JustifyAll does not), so no extra last-line disjunct belongs here.
625
        // The old gate justified every non-last line whenever text-justify
626
        // was not `none` - which is its DEFAULT (`auto` -> InterWord) - so
627
        // `text-align: left/center/right` paragraphs came out justified, and
628
        // lines before a forced break were justified against §6.3. The
629
        // greedy path documents the same trap (see cache.rs
630
        // "Without this check, ALL text gets justified").
631
349
        let should_justify = constraints.text_justify != JustifyContent::None
632
4
            && matches!(
633
34
                effective_align,
634
                TextAlign::Justify | TextAlign::JustifyAll
635
            );
636

            
637
        // Get the available width as f32 for calculations
638
        // For MinContent/MaxContent, we use the actual computed line_width
639
        // since there's no "available" space to justify into.
640
349
        let available_width_f32 = match constraints.available_width {
641
299
            AvailableSpace::Definite(w) => w,
642
18
            AvailableSpace::MaxContent => line_width,
643
32
            AvailableSpace::MinContent => line_width,
644
        };
645

            
646
349
        if should_justify {
647
30
            let space_to_add = available_width_f32 - line_width;
648
30
            if space_to_add > 0.0 {
649
2
                let space_count = line_items
650
2
                    .iter()
651
9
                    .filter(|item| is_word_separator(item))
652
2
                    .count();
653
2
                if space_count > 0 {
654
1
                    extra_per_space = space_to_add / space_count as f32;
655
1
                }
656
28
            }
657
319
        }
658

            
659
        // Alignment & Positioning
660
349
        let total_width: f32 = line_items
661
349
            .iter()
662
1564
            .map(|item| get_item_measure(item, false))
663
349
            .sum();
664

            
665
        // For MaxContent, don't apply alignment (treat as left-aligned)
666
349
        let is_indefinite = matches!(
667
349
            constraints.available_width,
668
            AvailableSpace::MaxContent | AvailableSpace::MinContent
669
        );
670
349
        let remaining_space = if is_indefinite {
671
50
            0.0
672
        } else {
673
299
            available_width_f32
674
299
                - (total_width
675
299
                    + extra_per_space
676
299
                        * line_items
677
299
                            .iter()
678
1512
                            .filter(|item| is_word_separator(item))
679
299
                            .count() as f32)
680
        };
681

            
682
        // +spec:writing-modes:155a06 - resolve start/end edges of line box per bidi direction
683
349
        let physical_align = match (effective_align, base_direction) {
684
4
            (TextAlign::Start, BidiDirection::Ltr) => TextAlign::Left,
685
1
            (TextAlign::Start, BidiDirection::Rtl) => TextAlign::Right,
686
5
            (TextAlign::End, BidiDirection::Ltr) => TextAlign::Right,
687
1
            (TextAlign::End, BidiDirection::Rtl) => TextAlign::Left,
688
338
            (other, _) => other,
689
        };
690

            
691
        // +spec:display-contents:5a1b30 - overflowing lines are start-aligned (overflow off end edge)
692
349
        let mut main_axis_pen = if remaining_space < 0.0 {
693
36
            0.0
694
        } else {
695
313
            match physical_align {
696
14
                TextAlign::Center => remaining_space / 2.0,
697
6
                TextAlign::Right => remaining_space,
698
293
                _ => 0.0,
699
            }
700
        };
701

            
702
        // +spec:display-contents:21b27a - text-indent applies to initial letter's originating line as usual
703
        // +spec:line-breaking:bc389d - text-indent with each-line/hanging keywords
704
349
        if constraints.text_indent != 0.0 {
705
            // TODO: with text-indent-each-line, also detect lines after forced breaks in the KP path
706
33
            let is_indent_target = line_index == 0;
707
33
            let should_indent = if constraints.text_indent_hanging {
708
10
                !is_indent_target
709
            } else {
710
23
                is_indent_target
711
            };
712
33
            if should_indent {
713
22
                main_axis_pen += constraints.text_indent;
714
22
            }
715
316
        }
716

            
717
1913
        for item in line_items {
718
1564
            let item_advance = get_item_measure(&item, false);
719

            
720
1564
            let draw_pos = match &item {
721
1528
                ShapedItem::Cluster(c) if !c.glyphs.is_empty() => {
722
                    let glyph = &c.glyphs[0];
723
                    Point {
724
                        x: main_axis_pen + glyph.offset.x,
725
                        y: cross_axis_pen - glyph.offset.y, // Use - for GPOS offset
726
                    }
727
                }
728
1564
                _ => Point {
729
1564
                    x: main_axis_pen,
730
1564
                    y: cross_axis_pen,
731
1564
                },
732
            };
733

            
734
1564
            positioned_items.push(PositionedItem {
735
1564
                item: item.clone(),
736
1564
                position: draw_pos,
737
1564
                line_index,
738
1564
            });
739

            
740
1564
            main_axis_pen += item_advance;
741

            
742
            // Apply extra spacing to the pen
743
1564
            if is_word_separator(&item) {
744
207
                main_axis_pen += extra_per_space;
745
1357
            }
746
        }
747

            
748
        // +spec:box-model:96f5a7 - line box height uses line-height only; inline margins/borders/padding do not enter calculation
749
349
        cross_axis_pen += constraints.resolved_line_height();
750
349
        start_node = end_node;
751
    }
752

            
753
127
    let mut layout = UnifiedLayout {
754
127
        items: positioned_items,
755
127
        overflow: OverflowInfo::default(),
756
127
    };
757
    // Record the unclipped content bounds. `overflow_items` stays empty by
758
    // design: every item is positioned (visual overflow is clipped at paint
759
    // time), so nothing is dropped here. TODO(superplan): populate
760
    // `overflow_items` only if a path actually discards content that doesn't fit.
761
127
    let bounds = layout.bounds();
762
127
    layout.overflow.unclipped_bounds = bounds;
763
127
    layout
764
127
}
765

            
766
#[cfg(test)]
767
mod kp_fix_tests {
768
    use super::*;
769
    use crate::text3::cache::{ShapedCluster, StyleProperties, UnifiedConstraints};
770
    use azul_core::selection::{ContentIndex, GraphemeClusterId};
771
    use azul_css::props::basic::FontRef;
772
    use std::sync::Arc;
773

            
774
36
    fn cl(text: &str, advance: f32) -> ShapedItem {
775
36
        ShapedItem::Cluster(ShapedCluster {
776
36
            flags: crate::text3::cache::ClusterFlags::classify(text),
777
36
            source_text: Arc::from(text), source_byte_len: text.len() as u16,
778
36
            source_cluster_id: GraphemeClusterId { source_run: 0, start_byte_in_run: 0 },
779
36
            source_content_index: ContentIndex { run_index: 0, item_index: 0 },
780
36
            source_node_id: None,
781
36
            glyphs: smallvec::SmallVec::new(),
782
36
            advance,
783
36
            direction: BidiDirection::Ltr,
784
36
            style: Arc::new(StyleProperties::default()),
785
36
            marker_position_outside: None,
786
36
            is_first_fragment: true,
787
36
            is_last_fragment: true,
788
36
        })
789
36
    }
790

            
791
4
    fn nodes_for(text: &str) -> Vec<LayoutNode> {
792
        // Build per-grapheme clusters like the real shaper. 12px letters, 6px '-', 5px space.
793
4
        let items: Vec<ShapedItem> = text
794
4
            .chars()
795
36
            .map(|c| {
796
36
                let s = c.to_string();
797
36
                let adv = match c {
798
3
                    ' ' => 5.0,
799
1
                    '-' => 6.0,
800
32
                    _ => 12.0,
801
                };
802
36
                cl(&s, adv)
803
36
            })
804
4
            .collect();
805
4
        let fonts: LoadedFonts<FontRef> = LoadedFonts::new();
806
4
        convert_items_to_nodes(
807
4
            &items,
808
4
            None,
809
4
            &fonts,
810
4
            &UnifiedConstraints::default(),
811
4
            BidiDirection::Ltr,
812
        )
813
4
    }
814

            
815
    #[test]
816
1
    fn bug1_terminal_break_wraps_word_ending_paragraph() {
817
        // "aaaa aaaa" (ends in a Box, no trailing space) must wrap at width 60.
818
1
        let nodes = nodes_for("aaaa aaaa");
819
1
        assert!(matches!(nodes.last(), Some(LayoutNode::Penalty { penalty, .. }) if *penalty <= -INFINITY_BADNESS),
820
            "a terminal forced break must be appended");
821
1
        let c = UnifiedConstraints { available_width: AvailableSpace::Definite(60.0), ..Default::default() };
822
1
        let breaks = find_optimal_breakpoints(&nodes, &c);
823
1
        assert!(breaks.len() >= 2, "must break into >=2 lines, got {breaks:?}");
824
1
        assert_eq!(*breaks.last().unwrap(), nodes.len(), "final break at n");
825
1
    }
826

            
827
    #[test]
828
1
    fn bug2_hyphen_is_break_opportunity() {
829
        // "aaaa-aaaa": a zero-width penalty must follow the '-' Box.
830
1
        let nodes = nodes_for("aaaa-aaaa");
831
        // find the '-' box and assert the next node is a zero-width penalty
832
1
        let mut found = false;
833
11
        for (i, n) in nodes.iter().enumerate() {
834
9
            if let LayoutNode::Box(ShapedItem::Cluster(cc), _) = n {
835
9
                if cc.text() == "-" {
836
1
                    match nodes.get(i + 1) {
837
1
                        Some(LayoutNode::Penalty { penalty, width, .. }) => {
838
1
                            assert!(*width == 0.0 && *penalty > -INFINITY_BADNESS,
839
                                "hyphen must be followed by a zero-width soft penalty");
840
1
                            found = true;
841
                        }
842
                        other => panic!("expected penalty after hyphen, got {other:?}"),
843
                    }
844
8
                }
845
2
            }
846
        }
847
1
        assert!(found, "hyphen box must exist");
848
        // and it must actually enable a wrap at width 60
849
1
        let c = UnifiedConstraints { available_width: AvailableSpace::Definite(60.0), ..Default::default() };
850
1
        let breaks = find_optimal_breakpoints(&nodes, &c);
851
1
        assert!(breaks.len() >= 2, "hyphenated token must wrap, got {breaks:?}");
852
1
    }
853

            
854
    #[test]
855
1
    fn bug4_min_content_breaks_every_word() {
856
        // "aaaa aaaa" min-content: two words => two content lines (widest = one word).
857
1
        let nodes = nodes_for("aaaa aaaa");
858
1
        let c = UnifiedConstraints { available_width: AvailableSpace::MinContent, ..Default::default() };
859
1
        let breaks = find_optimal_breakpoints(&nodes, &c);
860
        // there must be a break after the first word's trailing space penalty,
861
        // i.e. more than one break -> not a single spanning line.
862
1
        assert!(breaks.len() >= 2, "min-content must break per word, got {breaks:?}");
863
1
    }
864

            
865
    #[test]
866
1
    fn bug3_trailing_space_trimmed_from_line() {
867
        // "aaaa aaaa" @60 wraps to [.., 6, 11]; line 0 = "aaaa" + trailing space.
868
1
        let nodes = nodes_for("aaaa aaaa");
869
1
        let c = UnifiedConstraints {
870
1
            available_width: AvailableSpace::Definite(60.0),
871
1
            ..Default::default()
872
1
        };
873
1
        let breaks = find_optimal_breakpoints(&nodes, &c);
874
1
        let layout = position_lines_from_breaks(&nodes, &breaks, &[], &c);
875
        // The line-terminating space must not be positioned on line 0.
876
1
        let line0_spaces = layout
877
1
            .items
878
1
            .iter()
879
8
            .filter(|it| it.line_index == 0 && is_word_separator(&it.item))
880
1
            .count();
881
1
        assert_eq!(line0_spaces, 0, "trailing space must be trimmed from line 0");
882
        // Rightmost cluster edge on line 0 == 4*12 = 48px (not 53 incl. the space).
883
1
        let max_x = layout
884
1
            .items
885
1
            .iter()
886
8
            .filter(|it| it.line_index == 0)
887
4
            .filter_map(|it| it.item.as_cluster().map(|cc| it.position.x + cc.advance))
888
1
            .fold(0.0f32, f32::max);
889
1
        assert!((max_x - 48.0).abs() < 0.01, "line 0 right edge {max_x} should be 48px");
890
1
    }
891
}
892

            
893
#[cfg(test)]
894
#[allow(clippy::float_cmp)] // exact geometry: every advance below is an exact f32
895
#[allow(clippy::cast_precision_loss)] // small line counters
896
mod autotest_generated {
897
    use std::sync::Arc;
898

            
899
    use azul_core::selection::{ContentIndex, GraphemeClusterId};
900
    use azul_css::props::basic::FontRef;
901

            
902
    use super::*;
903
    use crate::text3::cache::{
904
        BreakType, ClearType, InlineBreak, InlineContent, LineHeight, Rect, ShapedCluster,
905
        StyleProperties,
906
    };
907

            
908
    // ---------------------------------------------------------------------
909
    // Builders. Every item is a single-grapheme cluster with an explicit
910
    // advance, mirroring what the shaper produces: 12px letters, 5px space,
911
    // 6px hyphen. No glyphs -> get_item_measure() == advance exactly, so all
912
    // expected coordinates below are exact integers.
913
    // ---------------------------------------------------------------------
914

            
915
    fn cl(text: &str, advance: f32) -> ShapedItem {
916
        ShapedItem::Cluster(ShapedCluster {
917
            flags: crate::text3::cache::ClusterFlags::classify(text),
918
            source_text: Arc::from(text), source_byte_len: text.len() as u16,
919
            source_cluster_id: GraphemeClusterId {
920
                source_run: 0,
921
                start_byte_in_run: 0,
922
            },
923
            source_content_index: ContentIndex {
924
                run_index: 0,
925
                item_index: 0,
926
            },
927
            source_node_id: None,
928
            glyphs: smallvec::SmallVec::new(),
929
            advance,
930
            direction: BidiDirection::Ltr,
931
            style: Arc::new(StyleProperties::default()),
932
            marker_position_outside: None,
933
            is_first_fragment: true,
934
            is_last_fragment: true,
935
        })
936
    }
937

            
938
    fn advance_of(c: char) -> f32 {
939
        match c {
940
            ' ' => 5.0,
941
            '-' => 6.0,
942
            _ => 12.0,
943
        }
944
    }
945

            
946
    /// One cluster per `char`, like the real shaper hands to `kp_layout`.
947
    fn items_of(text: &str) -> Vec<ShapedItem> {
948
        text.chars()
949
            .map(|c| cl(&c.to_string(), advance_of(c)))
950
            .collect()
951
    }
952

            
953
    fn no_fonts() -> LoadedFonts<FontRef> {
954
        LoadedFonts::new()
955
    }
956

            
957
    /// `convert_items_to_nodes` with hyphenation disabled (the hyphenator is a
958
    /// feature-gated type and needs a real dictionary; `None` is the path the
959
    /// engine takes whenever `hyphens: none`).
960
    fn nodes_of(items: &[ShapedItem]) -> Vec<LayoutNode> {
961
        convert_items_to_nodes(
962
            items,
963
            None,
964
            &no_fonts(),
965
            &UnifiedConstraints::default(),
966
            BidiDirection::Ltr,
967
        )
968
    }
969

            
970
    fn nodes_for(text: &str) -> Vec<LayoutNode> {
971
        nodes_of(&items_of(text))
972
    }
973

            
974
    fn definite(width: f32) -> UnifiedConstraints {
975
        UnifiedConstraints {
976
            available_width: AvailableSpace::Definite(width),
977
            ..Default::default()
978
        }
979
    }
980

            
981
    fn object(width: f32) -> ShapedItem {
982
        ShapedItem::Object {
983
            source: ContentIndex {
984
                run_index: 0,
985
                item_index: 0,
986
            },
987
            bounds: Rect {
988
                x: 0.0,
989
                y: 0.0,
990
                width,
991
                height: 10.0,
992
            },
993
            baseline_offset: 0.0,
994
            content: InlineContent::Tab {
995
                style: Arc::new(StyleProperties::default()),
996
            },
997
        }
998
    }
999

            
    fn tab(width: f32) -> ShapedItem {
        ShapedItem::Tab {
            source: ContentIndex {
                run_index: 0,
                item_index: 0,
            },
            bounds: Rect {
                x: 0.0,
                y: 0.0,
                width,
                height: 10.0,
            },
        }
    }
    fn hard_break() -> ShapedItem {
        ShapedItem::Break {
            source: ContentIndex {
                run_index: 0,
                item_index: 0,
            },
            break_info: InlineBreak {
                break_type: BreakType::Hard,
                clear: ClearType::None,
                content_index: 0,
            },
        }
    }
    fn rtl_logical() -> Vec<LogicalItem> {
        vec![LogicalItem::Text {
            source: ContentIndex {
                run_index: 0,
                item_index: 0,
            },
            text: Arc::from("\u{05E9}\u{05DC}\u{05D5}\u{05DD}"), // "שלום"
            style: Arc::new(StyleProperties::default()),
            marker_position_outside: None,
            source_node_id: None,
        }]
    }
    // ---------------------------------------------------------------------
    // Inspectors
    // ---------------------------------------------------------------------
    fn is_forced(node: &LayoutNode) -> bool {
        matches!(node, LayoutNode::Penalty { penalty, .. } if *penalty <= -INFINITY_BADNESS)
    }
    fn penalty_count(nodes: &[LayoutNode]) -> usize {
        nodes
            .iter()
            .filter(|n| matches!(n, LayoutNode::Penalty { .. }))
            .count()
    }
    fn line_text(layout: &UnifiedLayout, line: usize) -> String {
        layout
            .items
            .iter()
            .filter(|it| it.line_index == line)
            .filter_map(|it| it.item.as_cluster().map(|c| c.text()))
            .collect()
    }
    fn line_count(layout: &UnifiedLayout) -> usize {
        layout
            .items
            .iter()
            .map(|it| it.line_index + 1)
            .max()
            .unwrap_or(0)
    }
    fn line_left(layout: &UnifiedLayout, line: usize) -> f32 {
        layout
            .items
            .iter()
            .filter(|it| it.line_index == line)
            .map(|it| it.position.x)
            .fold(f32::INFINITY, f32::min)
    }
    fn line_right(layout: &UnifiedLayout, line: usize) -> f32 {
        layout
            .items
            .iter()
            .filter(|it| it.line_index == line)
            .map(|it| it.position.x + get_item_measure(&it.item, false))
            .fold(f32::NEG_INFINITY, f32::max)
    }
    /// Structural contract of `find_optimal_breakpoints`: the returned indices
    /// are strictly increasing, in range, and the paragraph ends at `n`.
    fn assert_breaks_well_formed(nodes: &[LayoutNode], breaks: &[usize], what: &str) {
        for w in breaks.windows(2) {
            assert!(
                w[0] < w[1],
                "{what}: breaks must be strictly increasing, got {breaks:?}"
            );
        }
        for &b in breaks {
            assert!(
                b <= nodes.len(),
                "{what}: break {b} out of range (n = {})",
                nodes.len()
            );
        }
        if !breaks.is_empty() {
            assert_eq!(
                *breaks.last().unwrap(),
                nodes.len(),
                "{what}: the paragraph must end at the last node"
            );
        }
    }
    // =====================================================================
    // convert_items_to_nodes: structure of the Box/Glue/Penalty stream
    // =====================================================================
    #[test]
    fn convert_empty_items_yields_no_nodes() {
        // The terminal-forced-break append is guarded on !nodes.is_empty(),
        // so an empty paragraph must stay empty (not gain a lone penalty).
        assert!(nodes_of(&[]).is_empty());
    }
    #[test]
    fn convert_appends_exactly_one_terminal_forced_break() {
        let nodes = nodes_for("ab cd");
        assert!(is_forced(nodes.last().unwrap()), "paragraph must be anchored");
        assert_eq!(
            nodes.iter().filter(|n| is_forced(n)).count(),
            1,
            "exactly one forced break for a paragraph with no explicit breaks"
        );
    }
    #[test]
    fn convert_does_not_duplicate_terminal_break_after_an_explicit_break() {
        let items = vec![cl("a", 12.0), hard_break()];
        let nodes = nodes_of(&items);
        assert_eq!(nodes.len(), 2, "Box + the Break's own forced Penalty, no more");
        assert!(is_forced(&nodes[1]));
        assert_eq!(nodes.iter().filter(|n| is_forced(n)).count(), 1);
    }
    #[test]
    fn convert_space_glue_uses_the_documented_stretch_shrink_ratios() {
        let nodes = nodes_for("a b");
        let glue = nodes
            .iter()
            .find(|n| matches!(n, LayoutNode::Glue { .. }))
            .expect("a space must become Glue");
        match glue {
            LayoutNode::Glue {
                width,
                stretch,
                shrink,
                ..
            } => {
                assert_eq!(*width, 5.0);
                assert_eq!(*stretch, 5.0 * SPACE_STRETCH_RATIO);
                assert_eq!(*shrink, 5.0 * SPACE_SHRINK_RATIO);
                assert!(
                    *shrink < *width,
                    "a space may never shrink past zero width"
                );
            }
            _ => unreachable!(),
        }
    }
    #[test]
    fn convert_zero_width_space_becomes_an_itemless_penalty() {
        // U+200B is a wrap opportunity with no glyph: it must produce a
        // zero-width, zero-cost Penalty and contribute no Box.
        let items = vec![cl("a", 12.0), cl("\u{200B}", 0.0), cl("b", 12.0)];
        let nodes = nodes_of(&items);
        let zwsp = match &nodes[1] {
            LayoutNode::Penalty {
                item,
                width,
                penalty,
            } => (item.is_none(), *width, *penalty),
            other => panic!("expected a Penalty for U+200B, got {other:?}"),
        };
        assert_eq!(zwsp, (true, 0.0, 0.0));
        // ...and it really is a break opportunity at a narrow width.
        let breaks = find_optimal_breakpoints(&nodes, &definite(12.0));
        assert_breaks_well_formed(&nodes, &breaks, "zwsp");
    }
    #[test]
    fn convert_both_hyphen_codepoints_are_soft_wrap_opportunities() {
        // U+002D HYPHEN-MINUS and U+2010 HYPHEN are UAX#14 class BA: a break is
        // allowed AFTER them, and no extra hyphen glyph is inserted.
        for hyphen in ['\u{002D}', '\u{2010}'] {
            let items = vec![cl("a", 12.0), cl(&hyphen.to_string(), 6.0), cl("b", 12.0)];
            let nodes = nodes_of(&items);
            match (&nodes[1], &nodes[2]) {
                (
                    LayoutNode::Box(ShapedItem::Cluster(c), w),
                    LayoutNode::Penalty {
                        item,
                        width,
                        penalty,
                    },
                ) => {
                    assert_eq!(c.text(), hyphen.to_string());
                    assert_eq!(*w, 6.0);
                    assert!(item.is_none(), "no extra hyphen glyph may be inserted");
                    assert_eq!(*width, 0.0);
                    assert!(
                        *penalty > -INFINITY_BADNESS,
                        "the hyphen break is optional, not forced"
                    );
                }
                other => panic!("expected Box('{hyphen}') + zero-width Penalty, got {other:?}"),
            }
        }
    }
    #[test]
    fn convert_atomic_inline_is_wrapped_in_wrap_opportunities() {
        // CSS Text 3 §5.1: a soft wrap opportunity exists before AND after each
        // replaced element / atomic inline.
        let nodes = nodes_of(&[object(40.0)]);
        assert!(matches!(nodes[0], LayoutNode::Penalty { .. }));
        assert!(matches!(nodes[1], LayoutNode::Box(_, w) if w == 40.0));
        assert!(matches!(nodes[2], LayoutNode::Penalty { .. }));
    }
    #[test]
    fn convert_tab_is_glue_and_not_a_wrap_opportunity() {
        // A tab is stretchable like a space but is NOT followed by a Penalty,
        // so no line may break at it.
        let nodes = nodes_of(&[cl("a", 12.0), tab(30.0), cl("b", 12.0)]);
        match &nodes[1] {
            LayoutNode::Glue {
                width,
                stretch,
                shrink,
                ..
            } => {
                assert_eq!(*width, 30.0);
                assert_eq!(*stretch, 30.0 * SPACE_STRETCH_RATIO);
                assert_eq!(*shrink, 30.0 * SPACE_SHRINK_RATIO);
            }
            other => panic!("a tab must become Glue, got {other:?}"),
        }
        assert_eq!(
            penalty_count(&nodes),
            1,
            "only the terminal forced break; a tab offers no wrap opportunity"
        );
    }
    #[test]
    fn nbsp_must_not_be_a_soft_wrap_opportunity() {
        // UAX#14 class GL. cache.rs suppresses NBSP /
        // NNBSP / WJ / ZWNBSP as break opportunities in the greedy path
        // (`is_break_opportunity_with_word_break`, "otherwise 10\u{00A0}km
        // wrongly wraps"), but convert_items_to_nodes keys off
        // `is_word_separator`, which reports NBSP as a separator -- so the
        // Knuth-Plass path emits Glue + Penalty and "10\u{00A0}km" CAN wrap at
        // the no-break space. See the report accompanying this test batch.
        for nbsp in ['\u{00A0}', '\u{202F}'] {
            let items = vec![cl("1", 12.0), cl(&nbsp.to_string(), 5.0), cl("k", 12.0)];
            let nodes = nodes_of(&items);
            let optional_breaks = nodes
                .iter()
                .filter(|n| matches!(n, LayoutNode::Penalty { .. }) && !is_forced(n))
                .count();
            assert_eq!(
                optional_breaks, 0,
                "U+{:04X} is a no-break space: it must not offer a wrap opportunity, got {nodes:?}",
                nbsp as u32
            );
        }
    }
    #[test]
    fn cjk_run_gains_inter_character_wrap_opportunities() {
        // U+3000 stays a non-separator (CSS Text §7.1), but word-break is
        // threaded through kp_layout now: under `normal`, CJK clusters offer
        // a soft-wrap opportunity after each ideograph, exactly like the
        // greedy path (line-breaking:16e64c). Two inter-cluster penalties +
        // the terminal forced break.
        let items = vec![cl("\u{4E00}", 16.0), cl("\u{3000}", 16.0), cl("\u{4E8C}", 16.0)];
        let nodes = nodes_of(&items);
        assert_eq!(
            penalty_count(&nodes),
            3,
            "two CJK inter-character opportunities plus the terminal break"
        );
        let breaks = find_optimal_breakpoints(&nodes, &definite(20.0));
        assert_breaks_well_formed(&nodes, &breaks, "cjk");
        // And the run actually wraps at 20px now.
        assert!(breaks.len() >= 2, "the CJK run must wrap: {breaks:?}");
    }
    #[test]
    fn convert_survives_hostile_unicode_without_panicking() {
        // Combining marks, an emoji ZWJ sequence, RTL text, a lone surrogate is
        // impossible in Rust, so use the next-worst thing: unpaired combining
        // marks, an unassigned plane-15 codepoint, and a zero-advance cluster.
        let items = vec![
            cl("e\u{0301}", 12.0),
            cl("\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}", 48.0),
            cl("\u{05D0}", 12.0),
            cl("\u{FFFD}", 12.0),
            cl("\u{F0000}", 0.0),
            cl("", 0.0),
        ];
        let nodes = nodes_of(&items);
        // Nothing is a separator or a hyphen, so every cluster stays a Box.
        assert_eq!(
            nodes.iter().filter(|n| matches!(n, LayoutNode::Box(..))).count(),
            items.len()
        );
        let layout = kp_layout(&items, &[], &definite(30.0), None, &no_fonts());
        assert_eq!(
            layout.items.len(),
            items.len(),
            "no cluster may be dropped by the layout"
        );
    }
    // =====================================================================
    // find_optimal_breakpoints: numeric limits & structural invariants
    // =====================================================================
    #[test]
    fn breakpoints_of_an_empty_paragraph_are_empty() {
        assert!(find_optimal_breakpoints(&[], &definite(100.0)).is_empty());
        assert!(find_optimal_breakpoints(&[], &UnifiedConstraints::default()).is_empty());
    }
    #[test]
    fn breakpoints_of_an_empty_paragraph_under_min_content_stay_in_range() {
        // The MinContent fast path appends nodes.len() unconditionally, so an
        // empty node list yields [0]. That must still be a safe input to the
        // positioner (kp_layout short-circuits before this, but the DP is
        // callable on its own).
        let breaks = find_optimal_breakpoints(
            &[],
            &UnifiedConstraints {
                available_width: AvailableSpace::MinContent,
                ..Default::default()
            },
        );
        assert_breaks_well_formed(&[], &breaks, "empty min-content");
        let layout = position_lines_from_breaks(
            &[],
            &breaks,
            &[],
            &UnifiedConstraints {
                available_width: AvailableSpace::MinContent,
                ..Default::default()
            },
        );
        assert!(layout.items.is_empty());
    }
    #[test]
    fn breaks_stay_well_formed_across_pathological_widths() {
        let nodes = nodes_for("aa bb cccc");
        let widths = [
            AvailableSpace::Definite(0.0),
            AvailableSpace::Definite(1.0),
            AvailableSpace::Definite(-100.0),
            AvailableSpace::Definite(f32::MIN),
            AvailableSpace::Definite(f32::MAX),
            AvailableSpace::Definite(f32::INFINITY),
            AvailableSpace::Definite(f32::NEG_INFINITY),
            AvailableSpace::Definite(f32::NAN),
            AvailableSpace::Definite(f32::EPSILON),
            AvailableSpace::MinContent,
            AvailableSpace::MaxContent,
        ];
        for w in widths {
            let c = UnifiedConstraints {
                available_width: w,
                ..Default::default()
            };
            let breaks = find_optimal_breakpoints(&nodes, &c);
            assert_breaks_well_formed(&nodes, &breaks, &format!("{w:?}"));
            // The positioner must survive whatever the DP produced.
            let layout = position_lines_from_breaks(&nodes, &breaks, &[], &c);
            assert!(
                layout.items.len() <= nodes.len(),
                "{w:?}: cannot position more items than there are nodes"
            );
        }
    }
    #[test]
    fn breaks_land_only_after_penalty_nodes_in_a_feasible_paragraph() {
        // Knuth-Plass may only break at a Penalty: `breaks[k]` is the index one
        // past the last node of a line, so nodes[breaks[k] - 1] must be one.
        let nodes = nodes_for("aa bb cccc");
        let breaks = find_optimal_breakpoints(&nodes, &definite(60.0));
        assert!(breaks.len() >= 2, "must wrap at 60px, got {breaks:?}");
        for &b in &breaks {
            assert!(
                matches!(nodes[b - 1], LayoutNode::Penalty { .. }),
                "break at {b} follows {:?}, which is not a legal break point",
                nodes[b - 1]
            );
        }
    }
    #[test]
    fn nan_advances_do_not_panic_or_hang() {
        let items = vec![
            cl("a", f32::NAN),
            cl(" ", f32::NAN),
            cl("b", f32::NAN),
            cl("c", 12.0),
        ];
        let nodes = nodes_of(&items);
        let c = definite(50.0);
        let breaks = find_optimal_breakpoints(&nodes, &c);
        assert_breaks_well_formed(&nodes, &breaks, "NaN advances");
        // Positioning NaN geometry may yield NaN coordinates, but must not panic
        // and must not lose content.
        let layout = position_lines_from_breaks(&nodes, &breaks, &[], &c);
        assert_eq!(
            layout
                .items
                .iter()
                .filter(|it| it.item.as_cluster().is_some_and(|cc| cc.text() != " "))
                .count(),
            3
        );
    }
    #[test]
    fn infinite_advance_collapses_to_one_overfull_line_without_panicking() {
        let items = vec![cl("a", f32::INFINITY)];
        let nodes = nodes_of(&items);
        let c = definite(100.0);
        let breaks = find_optimal_breakpoints(&nodes, &c);
        assert_breaks_well_formed(&nodes, &breaks, "infinite advance");
        let layout = position_lines_from_breaks(&nodes, &breaks, &[], &c);
        assert_eq!(layout.items.len(), 1);
        // Overflowing lines are start-aligned, so the pen never moves off zero.
        assert_eq!(layout.items[0].position.x, 0.0);
    }
    #[test]
    fn extreme_text_indent_does_not_panic() {
        let nodes = nodes_for("aa bb cccc");
        for indent in [f32::MAX, f32::MIN, -1000.0, f32::NAN, f32::INFINITY] {
            for hanging in [false, true] {
                let c = UnifiedConstraints {
                    available_width: AvailableSpace::Definite(100.0),
                    text_indent: indent,
                    text_indent_hanging: hanging,
                    ..Default::default()
                };
                let breaks = find_optimal_breakpoints(&nodes, &c);
                assert_breaks_well_formed(&nodes, &breaks, &format!("indent {indent}"));
                let layout = position_lines_from_breaks(&nodes, &breaks, &[], &c);
                assert!(layout.items.len() <= nodes.len());
            }
        }
    }
    #[test]
    fn zero_width_container_keeps_all_content() {
        // width: 0px is a genuinely zero-width container, not "unresolved":
        // every line overflows, but nothing may be dropped.
        let nodes = nodes_for("aa bb");
        let c = definite(0.0);
        let breaks = find_optimal_breakpoints(&nodes, &c);
        assert_breaks_well_formed(&nodes, &breaks, "zero width");
        let layout = position_lines_from_breaks(&nodes, &breaks, &[], &c);
        let letters = layout
            .items
            .iter()
            .filter(|it| it.item.as_cluster().is_some_and(|cc| cc.text() != " "))
            .count();
        assert_eq!(letters, 4, "all four letters must still be positioned");
    }
    #[test]
    fn min_content_breaks_at_every_penalty() {
        let nodes = nodes_for("aa bb cccc");
        let c = UnifiedConstraints {
            available_width: AvailableSpace::MinContent,
            ..Default::default()
        };
        let breaks = find_optimal_breakpoints(&nodes, &c);
        assert_breaks_well_formed(&nodes, &breaks, "min-content");
        // One break per Penalty node (the terminal penalty's break IS nodes.len()).
        assert_eq!(breaks.len(), penalty_count(&nodes));
        for &b in &breaks {
            assert!(matches!(nodes[b - 1], LayoutNode::Penalty { .. }));
        }
    }
    #[test]
    fn max_content_puts_an_unforced_paragraph_on_a_single_line() {
        let nodes = nodes_for("aa bb cccc");
        let c = UnifiedConstraints {
            available_width: AvailableSpace::MaxContent,
            ..Default::default()
        };
        let breaks = find_optimal_breakpoints(&nodes, &c);
        assert_eq!(
            breaks,
            vec![nodes.len()],
            "max-content must not wrap without a forced break"
        );
    }
    #[test]
    fn forced_break_item_starts_a_new_line_even_under_max_content() {
        let items = vec![cl("a", 12.0), hard_break(), cl("b", 12.0)];
        let c = UnifiedConstraints {
            available_width: AvailableSpace::MaxContent,
            ..Default::default()
        };
        let layout = kp_layout(&items, &[], &c, None, &no_fonts());
        assert_eq!(line_count(&layout), 2, "a Break must force a second line");
        assert_eq!(line_text(&layout, 0), "a");
        assert_eq!(line_text(&layout, 1), "b");
    }
    #[test]
    fn dp_terminates_on_a_paragraph_that_is_all_break_opportunities() {
        // 400 zero-width spaces: every node is a Penalty, i.e. the DP's worst
        // case (O(n^2) candidate pairs). It must terminate and produce a
        // well-formed, in-range break list.
        let items: Vec<ShapedItem> = (0..400).map(|_| cl("\u{200B}", 0.0)).collect();
        let nodes = nodes_of(&items);
        assert_eq!(penalty_count(&nodes), nodes.len());
        let breaks = find_optimal_breakpoints(&nodes, &definite(100.0));
        assert_breaks_well_formed(&nodes, &breaks, "all-penalty");
    }
    #[test]
    fn large_paragraph_keeps_every_glyph() {
        let text = "aaa ".repeat(300);
        let items = items_of(&text);
        let layout = kp_layout(&items, &[], &definite(100.0), None, &no_fonts());
        let letters = layout
            .items
            .iter()
            .filter(|it| it.item.as_cluster().is_some_and(|c| c.text() == "a"))
            .count();
        assert_eq!(letters, 900, "no glyph may be lost while wrapping");
        // line_index is emitted in reading order.
        assert!(layout
            .items
            .windows(2)
            .all(|w| w[0].line_index <= w[1].line_index));
    }
    #[test]
    fn a_multi_line_paragraph_must_not_collapse_onto_one_overfull_line() {
        // `breakpoints[].demerit` is seeded with
        // INFINITY_BADNESS, a *finite* 10_000 -- but demerits ACCUMULATE across
        // lines (`demerit = badness + breakpoints[j].demerit`). Once the optimal
        // path's cumulative demerit passes 10_000, no candidate can ever satisfy
        // `demerit < breakpoints[i + 1].demerit` again, so no further breakpoint
        // is recorded and the backtrack falls through the default
        // `previous: 0` -- putting the entire paragraph on one overfull line.
        //
        // Here each 2-word line has ratio 3.6 -> badness ~4_666, so the third
        // line (cumulative ~13_997) is already unrepresentable. Any paragraph of
        // loose lines hits this; even perfectly-fitting lines hit it once they
        // are numerous enough. Fix: seed `demerit` with f32::INFINITY instead of
        // reusing the badness constant as the sentinel.
        let text = "aaa ".repeat(8); // 8 words, 36px each + 5px spaces
        let items = items_of(&text);
        let layout = kp_layout(&items, &[], &definite(100.0), None, &no_fonts());
        let lines = line_count(&layout);
        assert!(
            lines >= 4,
            "at most 2 of these 41px words fit per 100px line, so 8 words need \
             >= 4 lines; got {lines}"
        );
        for line in 0..lines {
            assert!(
                line_right(&layout, line) <= 100.5,
                "line {line} runs to {}px in a 100px container",
                line_right(&layout, line)
            );
        }
    }
    // =====================================================================
    // position_lines_from_breaks: geometry, alignment, justification
    // =====================================================================
    #[test]
    fn position_with_no_breaks_yields_an_empty_layout() {
        let nodes = nodes_for("ab");
        let layout = position_lines_from_breaks(&nodes, &[], &[], &definite(100.0));
        assert!(layout.items.is_empty());
        assert_eq!(layout.overflow.unclipped_bounds.width, 0.0);
    }
    #[test]
    fn position_tolerates_a_repeated_break_index() {
        // A degenerate empty line (start == end) must not panic or shift content.
        let nodes = nodes_for("aa bb cccc");
        let n = nodes.len();
        let layout = position_lines_from_breaks(&nodes, &[8, 8, n], &[], &definite(100.0));
        assert_eq!(line_text(&layout, 0), "aa bb");
        assert_eq!(line_text(&layout, 1), "", "the empty line holds nothing");
        assert_eq!(line_text(&layout, 2), "cccc");
    }
    #[test]
    fn lines_advance_by_exactly_the_resolved_line_height() {
        let nodes = nodes_for("aa bb cccc");
        let c = UnifiedConstraints {
            available_width: AvailableSpace::Definite(60.0),
            line_height: LineHeight::Px(20.0),
            ..Default::default()
        };
        let breaks = find_optimal_breakpoints(&nodes, &c);
        let layout = position_lines_from_breaks(&nodes, &breaks, &[], &c);
        assert_eq!(c.resolved_line_height(), 20.0);
        for it in &layout.items {
            assert_eq!(
                it.position.y,
                20.0 * it.line_index as f32,
                "line {} must sit at {}px",
                it.line_index,
                20.0 * it.line_index as f32
            );
        }
    }
    #[test]
    fn left_aligned_pen_starts_at_zero_and_never_moves_backwards() {
        let nodes = nodes_for("aa bb cccc");
        let c = definite(60.0);
        let breaks = find_optimal_breakpoints(&nodes, &c);
        let layout = position_lines_from_breaks(&nodes, &breaks, &[], &c);
        for line in 0..line_count(&layout) {
            let xs: Vec<f32> = layout
                .items
                .iter()
                .filter(|it| it.line_index == line)
                .map(|it| it.position.x)
                .collect();
            assert_eq!(xs[0], 0.0, "line {line} must start at the left edge");
            assert!(
                xs.windows(2).all(|w| w[0] <= w[1]),
                "the pen must advance monotonically on line {line}: {xs:?}"
            );
        }
    }
    #[test]
    fn center_and_right_alignment_use_the_exact_remaining_space() {
        // "aaaa" = 48px in a 100px box -> 52px of slack.
        let nodes = nodes_for("aaaa");
        for (align, expected_left) in [
            (TextAlign::Left, 0.0f32),
            (TextAlign::Center, 26.0),
            (TextAlign::Right, 52.0),
            (TextAlign::Start, 0.0),
            (TextAlign::End, 52.0),
        ] {
            let c = UnifiedConstraints {
                available_width: AvailableSpace::Definite(100.0),
                text_align: align,
                ..Default::default()
            };
            let breaks = find_optimal_breakpoints(&nodes, &c);
            let layout = position_lines_from_breaks(&nodes, &breaks, &[], &c);
            assert_eq!(
                line_left(&layout, 0),
                expected_left,
                "{align:?} must place the line at {expected_left}px"
            );
            assert_eq!(line_right(&layout, 0), expected_left + 48.0);
        }
    }
    #[test]
    fn an_overflowing_line_is_start_aligned_even_when_right_aligned() {
        // +spec: overflowing lines overflow the END edge, so the pen stays at 0
        // instead of going negative.
        let nodes = nodes_for("aaaaaaaa"); // 96px
        for align in [TextAlign::Right, TextAlign::Center, TextAlign::End] {
            let c = UnifiedConstraints {
                available_width: AvailableSpace::Definite(50.0),
                text_align: align,
                ..Default::default()
            };
            let breaks = find_optimal_breakpoints(&nodes, &c);
            let layout = position_lines_from_breaks(&nodes, &breaks, &[], &c);
            assert_eq!(
                line_left(&layout, 0),
                0.0,
                "{align:?}: an overfull line must not be pushed to a negative x"
            );
            assert_eq!(line_text(&layout, 0), "aaaaaaaa", "no glyph may be dropped");
        }
    }
    #[test]
    fn rtl_base_direction_flips_logical_start_and_end() {
        // 3 clusters = 36px in a 100px box -> 64px of slack.
        let nodes = nodes_for("abc");
        let c_start = UnifiedConstraints {
            available_width: AvailableSpace::Definite(100.0),
            text_align: TextAlign::Start,
            ..Default::default()
        };
        let c_end = UnifiedConstraints {
            text_align: TextAlign::End,
            ..c_start.clone()
        };
        let breaks = find_optimal_breakpoints(&nodes, &c_start);
        // LTR paragraph: start = left, end = right.
        assert_eq!(
            line_left(&position_lines_from_breaks(&nodes, &breaks, &[], &c_start), 0),
            0.0
        );
        assert_eq!(
            line_left(&position_lines_from_breaks(&nodes, &breaks, &[], &c_end), 0),
            64.0
        );
        // RTL paragraph (Hebrew logical items): start = right, end = left.
        let rtl = rtl_logical();
        assert_eq!(get_base_direction_from_logical(&rtl), BidiDirection::Rtl);
        assert_eq!(
            line_left(
                &position_lines_from_breaks(&nodes, &breaks, &rtl, &c_start),
                0
            ),
            64.0
        );
        assert_eq!(
            line_left(&position_lines_from_breaks(&nodes, &breaks, &rtl, &c_end), 0),
            0.0
        );
    }
    #[test]
    fn justification_stretches_inner_lines_and_leaves_the_last_line_alone() {
        // "aa bb cccc" @60px wraps to ["aa bb ", "cccc"]. Line 0 is 53px wide
        // after trimming its hanging space, so its single interior space must
        // absorb all 7px of slack; the last line must stay at its natural width.
        let nodes = nodes_for("aa bb cccc");
        let c = UnifiedConstraints {
            available_width: AvailableSpace::Definite(60.0),
            text_align: TextAlign::Justify,
            text_justify: JustifyContent::InterWord,
            ..Default::default()
        };
        let breaks = find_optimal_breakpoints(&nodes, &c);
        let layout = position_lines_from_breaks(&nodes, &breaks, &[], &c);
        assert_eq!(line_count(&layout), 2, "breaks: {breaks:?}");
        assert_eq!(line_text(&layout, 0), "aa bb");
        assert_eq!(line_text(&layout, 1), "cccc");
        assert_eq!(line_right(&layout, 0), 60.0, "inner line must be justified");
        assert_eq!(
            line_right(&layout, 1),
            48.0,
            "text-align: justify must not stretch the last line"
        );
    }
    #[test]
    fn word_break_break_all_grants_intra_word_breaks_in_the_kp_path() {
        use crate::text3::cache::WordBreak;
        // "aaaa" = four 12px clusters, no spaces. At 30px it can only wrap if
        // word-break: break-all inserts opportunities between clusters.
        let nodes_normal = {
            let items: Vec<ShapedItem> = "aaaa".chars().map(|ch| cl(&ch.to_string(), 12.0)).collect();
            let fonts: LoadedFonts<FontRef> = LoadedFonts::new();
            let c = UnifiedConstraints::default();
            convert_items_to_nodes(&items, None, &fonts, &c, BidiDirection::Ltr)
        };
        let n_pen_normal = nodes_normal.iter().filter(|n| matches!(n, LayoutNode::Penalty { .. })).count();
        let nodes_break_all = {
            let items: Vec<ShapedItem> = "aaaa".chars().map(|ch| cl(&ch.to_string(), 12.0)).collect();
            let fonts: LoadedFonts<FontRef> = LoadedFonts::new();
            let c = UnifiedConstraints {
                word_break: WordBreak::BreakAll,
                ..Default::default()
            };
            convert_items_to_nodes(&items, None, &fonts, &c, BidiDirection::Ltr)
        };
        let n_pen_break_all = nodes_break_all.iter().filter(|n| matches!(n, LayoutNode::Penalty { .. })).count();
        assert!(
            n_pen_break_all > n_pen_normal,
            "break-all must add intra-word opportunities: normal={n_pen_normal}, break-all={n_pen_break_all}"
        );
        // Three inter-cluster boundaries + the terminal forced break.
        assert_eq!(n_pen_break_all, n_pen_normal + 3);
    }
    #[test]
    fn hyphenation_is_suppressed_for_direction_mismatched_words() {
        // An LTR word inside an RTL paragraph must not be hyphenated (CSS 2.2
        // §9.10 note): the hyphen would land visually mid-line. Pin at the
        // node level: with a mismatched base direction, no penalty carries a
        // hyphen item even with a hyphenator present.
        #[cfg(not(feature = "text_layout_hyphenation"))]
        return; // dictionary feature not embedded in this build
        #[cfg(feature = "text_layout_hyphenation")]
        {
        use hyphenation::Load;
        let Ok(hyph) = Standard::from_embedded(hyphenation::Language::EnglishUS) else {
            return;
        };
        // Glyph-less test clusters PANIC inside find_all_hyphenation_breaks
        // (it indexes cluster.glyphs), so this test is doubly binding: with a
        // mismatched direction the hyphenator must never be CONSULTED - if
        // the gate regressed, the panic itself would fail the test before
        // the assertion does.
        let items: Vec<ShapedItem> = "hyphenation"
            .chars()
            .map(|ch| cl(&ch.to_string(), 12.0))
            .collect();
        let fonts: LoadedFonts<FontRef> = LoadedFonts::new();
        let c = UnifiedConstraints::default();
        let mismatched =
            convert_items_to_nodes(&items, Some(&hyph), &fonts, &c, BidiDirection::Rtl);
        let hyphen_penalties = mismatched
            .iter()
            .filter(|n| matches!(n, LayoutNode::Penalty { item: Some(_), .. }))
            .count();
        assert_eq!(
            hyphen_penalties, 0,
            "an LTR word in an RTL paragraph must not receive hyphen penalties"
        );
        }
    }
    #[test]
    fn left_aligned_paragraphs_are_never_justified() {
        // text-justify DEFAULTS to a non-none method (auto -> inter-word), so
        // the justification gate must key on the RESOLVED alignment, not on
        // text-justify alone. The old gate justified every non-last line of a
        // left-aligned paragraph.
        let nodes = nodes_for("aa bb cccc");
        let c = UnifiedConstraints {
            available_width: AvailableSpace::Definite(60.0),
            text_align: TextAlign::Left,
            text_justify: JustifyContent::InterWord,
            ..Default::default()
        };
        let breaks = find_optimal_breakpoints(&nodes, &c);
        let layout = position_lines_from_breaks(&nodes, &breaks, &[], &c);
        assert_eq!(line_count(&layout), 2, "breaks: {breaks:?}");
        assert_eq!(
            line_right(&layout, 0),
            53.0,
            "a left-aligned inner line keeps its natural width (was stretched to 60)"
        );
    }
    #[test]
    fn interior_hyphenation_opportunities_render_no_hyphen_and_add_no_width() {
        use super::LayoutNode;
        // Hand-built paragraph: "aa[-]bb cc" where [-] is a hyphenation
        // PENALTY (item = a 6px "-" cluster) that the break DOES NOT use at
        // width 80 (everything fits on one line). Knuth-Plass must neither
        // render that hyphen nor count its width.
        let aa = cl("aa", 24.0);
        let bb = cl("bb", 24.0);
        let sp = cl(" ", 5.0);
        let ccx = cl("cc", 24.0);
        let hyphen = cl("-", 6.0);
        let nodes = vec![
            LayoutNode::Box(aa, 24.0),
            LayoutNode::Penalty { penalty: 50.0, width: 6.0, item: Some(hyphen) },
            LayoutNode::Box(bb, 24.0),
            LayoutNode::Glue { item: sp, width: 5.0, stretch: 2.5, shrink: 1.6 },
            LayoutNode::Box(ccx, 24.0),
            LayoutNode::Penalty { penalty: -INFINITY_BADNESS, width: 0.0, item: None },
        ];
        let c = UnifiedConstraints {
            available_width: AvailableSpace::Definite(120.0),
            ..Default::default()
        };
        let breaks = find_optimal_breakpoints(&nodes, &c);
        let layout = position_lines_from_breaks(&nodes, &breaks, &[], &c);
        assert_eq!(line_count(&layout), 1, "everything fits: breaks {breaks:?}");
        let text = line_text(&layout, 0);
        assert!(
            !text.contains('-'),
            "an UNUSED hyphenation opportunity must not render its hyphen: {text:?}"
        );
        assert_eq!(
            line_right(&layout, 0),
            24.0 + 24.0 + 5.0 + 24.0,
            "the unused penalty's 6px must not count into the line width"
        );
    }
    #[test]
    fn justification_of_a_space_less_line_does_not_divide_by_zero() {
        // The only space is the line-terminating one, which is trimmed before
        // the space count is taken: extra_per_space must stay 0, never NaN/inf.
        let nodes = nodes_for("aaaa aaaa");
        let c = UnifiedConstraints {
            available_width: AvailableSpace::Definite(60.0),
            text_align: TextAlign::Justify,
            text_justify: JustifyContent::InterWord,
            ..Default::default()
        };
        let breaks = find_optimal_breakpoints(&nodes, &c);
        let layout = position_lines_from_breaks(&nodes, &breaks, &[], &c);
        assert!(!layout.items.is_empty());
        for it in &layout.items {
            assert!(
                it.position.x.is_finite() && it.position.y.is_finite(),
                "no coordinate may be NaN or infinite: {:?}",
                it.position
            );
        }
        assert_eq!(line_right(&layout, 0), 48.0, "nothing to stretch, no stretch");
    }
    #[test]
    fn text_indent_offsets_only_the_first_line() {
        let nodes = nodes_for("aa bb cccc");
        let c = UnifiedConstraints {
            available_width: AvailableSpace::Definite(80.0),
            text_indent: 10.0,
            ..Default::default()
        };
        let breaks = find_optimal_breakpoints(&nodes, &c);
        let layout = position_lines_from_breaks(&nodes, &breaks, &[], &c);
        assert_eq!(line_count(&layout), 2, "breaks: {breaks:?}");
        assert_eq!(line_left(&layout, 0), 10.0, "first line is indented");
        assert_eq!(line_left(&layout, 1), 0.0, "later lines are not");
    }
    #[test]
    fn hanging_text_indent_offsets_every_line_but_the_first() {
        let nodes = nodes_for("aa bb cccc");
        let c = UnifiedConstraints {
            available_width: AvailableSpace::Definite(80.0),
            text_indent: 10.0,
            text_indent_hanging: true,
            ..Default::default()
        };
        let breaks = find_optimal_breakpoints(&nodes, &c);
        let layout = position_lines_from_breaks(&nodes, &breaks, &[], &c);
        assert_eq!(line_count(&layout), 2, "breaks: {breaks:?}");
        assert_eq!(line_left(&layout, 0), 0.0, "hanging: first line is flush");
        assert_eq!(line_left(&layout, 1), 10.0, "hanging: later lines indent");
    }
    #[test]
    fn every_line_trims_its_hanging_space() {
        // CSS Text 3 §4.1.2: line-terminating spaces hang; they are not measured
        // and are not justification opportunities.
        let nodes = nodes_for("aaaa aaaa aaaa");
        let c = definite(60.0);
        let breaks = find_optimal_breakpoints(&nodes, &c);
        let layout = position_lines_from_breaks(&nodes, &breaks, &[], &c);
        assert!(line_count(&layout) >= 2, "breaks: {breaks:?}");
        for line in 0..line_count(&layout) {
            let text = line_text(&layout, line);
            assert!(
                !text.ends_with(' '),
                "line {line} kept its hanging space: {text:?}"
            );
            assert!(!text.is_empty());
        }
    }
    #[test]
    fn unclipped_bounds_enclose_every_positioned_item() {
        let nodes = nodes_for("aa bb cccc");
        let c = UnifiedConstraints {
            available_width: AvailableSpace::Definite(60.0),
            line_height: LineHeight::Px(20.0),
            ..Default::default()
        };
        let breaks = find_optimal_breakpoints(&nodes, &c);
        let layout = position_lines_from_breaks(&nodes, &breaks, &[], &c);
        let b = layout.overflow.unclipped_bounds;
        assert!(layout.overflow.overflow_items.is_empty(), "nothing is dropped");
        for it in &layout.items {
            assert!(
                it.position.x >= b.x - 0.01 && it.position.x <= b.x + b.width + 0.01,
                "item at {:?} escapes the recorded bounds {b:?}",
                it.position
            );
            assert!(it.position.y >= b.y - 0.01 && it.position.y <= b.y + b.height + 0.01);
        }
    }
    // =====================================================================
    // kp_layout: end-to-end smoke over extreme inputs
    // =====================================================================
    #[test]
    fn kp_layout_of_an_empty_paragraph_is_empty() {
        let layout = kp_layout(&[], &[], &definite(100.0), None, &no_fonts());
        assert!(layout.items.is_empty());
        assert!(layout.overflow.overflow_items.is_empty());
        assert_eq!(layout.overflow.unclipped_bounds.width, 0.0);
        assert_eq!(layout.overflow.unclipped_bounds.height, 0.0);
    }
    #[test]
    fn kp_layout_round_trips_the_paragraph_text_minus_hanging_spaces() {
        let items = items_of("aa bb cccc");
        let layout = kp_layout(&items, &[], &definite(60.0), None, &no_fonts());
        let round_tripped: String = (0..line_count(&layout))
            .map(|l| line_text(&layout, l))
            .collect::<Vec<_>>()
            .join(" ");
        assert_eq!(
            round_tripped, "aa bb cccc",
            "re-joining the lines with their trimmed break spaces must \
             reproduce the source text"
        );
    }
    #[test]
    fn kp_layout_no_panic_smoke_over_extreme_inputs() {
        let inputs: Vec<Vec<ShapedItem>> = vec![
            Vec::new(),
            items_of(" "),
            items_of("   "),
            items_of("-"),
            items_of("a-b"),
            items_of("\u{200B}"),
            vec![object(0.0)],
            vec![object(f32::MAX)],
            vec![tab(0.0), tab(f32::INFINITY)],
            vec![hard_break(), hard_break()],
            vec![cl("a", -50.0), cl(" ", -5.0), cl("b", -50.0)],
            vec![cl("a", f32::MAX), cl(" ", f32::MAX), cl("b", f32::MAX)],
            vec![cl("x", f32::NAN), tab(f32::NAN), object(f32::NAN)],
        ];
        let constraints = [
            definite(0.0),
            definite(-10.0),
            definite(f32::NAN),
            definite(f32::MAX),
            UnifiedConstraints {
                available_width: AvailableSpace::MinContent,
                text_align: TextAlign::JustifyAll,
                text_justify: JustifyContent::Distribute,
                ..Default::default()
            },
            UnifiedConstraints {
                available_width: AvailableSpace::MaxContent,
                text_align: TextAlign::End,
                text_align_last: TextAlign::Center,
                text_indent: -25.0,
                ..Default::default()
            },
        ];
        for items in &inputs {
            for c in &constraints {
                let layout = kp_layout(items, &[], c, None, &no_fonts());
                assert!(
                    layout.items.len() <= items.len() + 2,
                    "no item may be duplicated: {} positioned from {} shaped",
                    layout.items.len(),
                    items.len()
                );
                assert!(layout.overflow.overflow_items.is_empty());
            }
        }
    }
}