Skip to main content

rustyfi_backend/
linebreak.rs

1//! Paragraph breaking. Knuth–Plass optimal line breaking over glue
2//! and discretionary breakpoints. The input model (a flat `Vec<HorzBox>` of
3//! strings, glue and discretionaries) matches what lineBreak.ml consumes.
4//!
5//! Deviations from lineBreak.ml (v0.0.6), noted where they matter:
6//! - v0.0.6 builds a DAG over `DiscretionaryID`s (hyphenation points) and
7//!   finds a shortest path through it (see `LineBreakGraph`, `update_graph`
8//!   in lineBreak.ml). We run the classic Knuth–Plass dynamic program
9//!   directly over `is_break_point` candidates (glue or `Discretionary`)
10//!   instead of materializing a graph; a forced break (penalty `<=
11//!   FORCED_BREAK_PENALTY`, e.g. UAX#14 `Mandatory`) is modeled as a `floor`
12//!   that ratchets forward so no later line can span back over it, rather
13//!   than as a distinct graph node kind.
14//! - v0.0.6 drops `LBTooShort` edges entirely (a breakpoint pair that can't
15//!   stretch enough is simply unreachable that way) and only tolerates
16//!   `LBTooLong` a bounded number of times with a fixed `badness_for_too_long
17//!   = 100_000` (lineBreak.ml lines 985-1027). Since we must always be able
18//!   to typeset *something* (an overfull unbreakable word must still
19//!   produce one line, never a panic or a stuck search), every candidate
20//!   line stays representable: we cap its badness at `BADNESS_TOO_LONG`
21//!   instead of excluding it.
22//! - v0.0.6's badness is QUANTIZED and ours is continuous. `calculate_badness`
23//!   is `(abs (int_of_float (pure_ratio ** 3.))) * 10000` (lineBreak.ml:986):
24//!   `int_of_float` truncates toward zero BEFORE the scale, so every
25//!   permissible line with `|ratio| < 1` costs exactly 0 — and since
26//!   `ratio_shrink_limit` is `-1`, that means EVERY permissible overfull line
27//!   is free. Upstream is therefore indifferent across a wide band of line
28//!   shapes and lets the graph decide: `LBTooShort` deletes the edge outright,
29//!   and among the surviving all-zero paths `shortest_path`'s relaxation order
30//!   picks one. We have neither half of that (a DP over every candidate line
31//!   has no edges to delete), so `10000 * |ratio|³` is what keeps our breaker
32//!   from packing to the feasibility limit.
33//!
34//!   Adopting upstream's exact formula here was TRIED AND MEASURED, not
35//!   assumed: it regresses 6 of the 7 layout-fidelity corpus documents
36//!   (`layout-tests/fidelity.py`), and quantizing moves easytable and
37//!   enumitem's line counts DOWN, because `LINE_PENALTY` becomes the only
38//!   thing separating partitions whose lines are all inside the free band.
39//!   figbox's page gap does not close. Do not re-derive this from
40//!   lineBreak.ml alone; the cost model and the graph structure are a
41//!   package, and we only have the one.
42//!
43//!   Do NOT argue this from line counts measured by clustering `pdftotext`
44//!   GLYPH BOXES: their tops and bottoms come from the font descriptor, which
45//!   the two writers do not emit alike. That metric made the port look short
46//!   (easytable 556 vs 592, enumitem 869 vs 885); counted from the PDF content
47//!   stream the two engines set 565 vs 565 and 882 vs 883 — easytable was never
48//!   short at all. The experiment's OUTCOME above stands (it was a
49//!   whole-harness comparison); "we are already short, so do not go shorter"
50//!   was never a valid reason for it.
51//!
52//!   RETRIED after `text_to_boxes` started emitting inter-CJK glue at PREVENTED
53//!   boundaries as well (upstream's `LBPure` arm), in case quantizing behaves
54//!   differently once every boundary is elastic. It does not: easytable 555 ->
55//!   553 lines, enumitem 869 -> 868, and the gate fails 7 ways (easytable
56//!   `text_match` 0.8743 -> 0.8539). More elasticity puts MORE lines inside the
57//!   free band, so `LINE_PENALTY` gets more say, not less.
58//!
59//!   RETRIED a third time, after the inter-script space was made rigid and the
60//!   JLreq class spaces were rescaled to the corrected font size — i.e. once
61//!   the stretch budget of a Japanese line was itself correct, which is the
62//!   input the free band is measured against. This is the closest it has come
63//!   and it still does not land: every `width_p95` improves (latexcmds 0.634 ->
64//!   0.631, xpath 0.160 -> 0.138, enumitem 0.547 -> 0.527, easytable 0.650 ->
65//!   0.622, figbox 0.693 -> 0.665) and no line or page count moves, but figbox
66//!   DROPS a character (`chars_missing` 0 -> 1) and the gate fails on it. The
67//!   diagnosis below is unchanged and is the reason: a free band is only
68//!   survivable with a search that breaks ties the way upstream's does, and
69//!   tightening the cost model cannot supply one.
70//!
71//!   And a note on what the remaining gap actually is, so the next reader does
72//!   not look for a cost that closes it. Upstream's weight is `badness +
73//!   pnltybreak` with NO per-line term, so within the free band every partition
74//!   of a paragraph scores exactly 0 whatever its line count. Which one comes
75//!   out is decided by `FlowGraph.shortest_path`: labels only ever improve on a
76//!   STRICT `<` (`flowGraph.ml:200`), so each vertex keeps the first parent that
77//!   reached it, and the pop order among all-zero distances comes from a
78//!   `Pairing_heap` seeded by `MainTable.iter` — a `Hashtbl`. Upstream's break
79//!   placement inside the free band is therefore hash order, not a preference,
80//!   and this DP's "minimize |ratio|" is a substitute for indifference rather
81//!   than an approximation of a target. It systematically packs to the
82//!   feasibility limit; upstream lands somewhere arbitrary short of it. That is
83//!   the line-packing floor the port's notes record as proven.
84
85use crate::context::Context;
86use crate::hbox::{HorzBox, InlineMarkKind, PureHorzBox, FORCED_BREAK_PENALTY};
87use crate::length::Length;
88use crate::vbox::VertBox;
89
90/// A UAX#14 break opportunity's kind, reduced to the two outcomes the
91/// paragraph breaker needs (v0.0.6's ~40-rule engine over `LineBreak.txt`
92/// classes, `ref:src/chardecoder/lineBreakDataMap.ml`, collapses the same
93/// way into `append_break_opportunity`'s direct/mandatory distinction).
94#[derive(Clone, Copy, Debug, PartialEq, Eq)]
95pub enum BreakKind {
96    /// A break is legal but optional (an ordinary word/punctuation
97    /// boundary) — a `Discretionary` candidate.
98    Allowed,
99    /// A break is required (e.g. a literal newline) — the paragraph
100    /// breaker must end a line here (see `FORCED_BREAK_PENALTY`).
101    Mandatory,
102}
103
104/// Unicode line-breaking (UAX#14) opportunities in `text`, as
105/// `(byte_offset, kind)` pairs in ascending order (`unicode-linebreak`'s
106/// `linebreaks`, a compiled pair table — no unidata files to ship). Does
107/// *not* do v0.0.6's script/East-Asian-width segmentation or JLreq
108/// tailoring (`ref:src/chardecoder/scriptDataMap.ml`) for the evaluated
109/// alternatives.
110pub fn break_opportunities(text: &str) -> Vec<(usize, BreakKind)> {
111    unicode_linebreak::linebreaks(text)
112        .map(|(i, opp)| {
113            let kind = match opp {
114                unicode_linebreak::BreakOpportunity::Mandatory => BreakKind::Mandatory,
115                unicode_linebreak::BreakOpportunity::Allowed => BreakKind::Allowed,
116            };
117            (i, kind)
118        })
119        .collect()
120}
121
122/// Classic Knuth–Plass default line penalty. NOT a lineBreak.ml constant:
123/// v0.0.6's edge weight is `badness + pnltybreak`, where `pnltybreak` comes
124/// from a `HorzDiscretionary`'s own penalty (lineBreak.ml:1012) — the same
125/// role our `Discretionary::penalty` plays via `demerits`. We adopt TeX's
126/// classic default line penalty as the flat part, folded into `demerits =
127/// (LINE_PENALTY + badness)^2 [+/- penalty^2]`.
128const LINE_PENALTY: f64 = 10.0;
129
130/// SATySFi's ratio limits (lineBreak.ml:507-508): a line stretched beyond
131/// `+2.0` is `LBTooShort` (dropped); shrunk beyond `-1.0` is `LBTooLong`.
132const RATIO_STRETCH_LIMIT: f64 = 2.0;
133const RATIO_SHRINK_LIMIT: f64 = -1.0;
134/// `badness_for_too_long` (lineBreak.ml:989) — the cost of a kept `LBTooLong`
135/// line; also the cap for the elastic `|ratio|³·10000` badness.
136const BADNESS_TOO_LONG: f64 = 100_000.0;
137/// A dropped (`LBTooShort`) line: huge so the DP never prefers it, but finite
138/// so a paragraph with no feasible partition still typesets.
139const BADNESS_DROPPED: f64 = 1.0e12;
140
141/// A candidate line's shape, used both to score it (badness/demerits) and
142/// to lay it out once chosen.
143struct LineMetrics {
144    natural: Length,
145    stretch: Length,
146    shrink: Length,
147    has_fil: bool,
148    /// Natural width contributed by CJK (ideographic/kana/CJK-punctuation)
149    /// glyphs on this line. Accumulated but NOT scored.
150    cjk_natural: Length,
151    /// Whether the line contains any real (breakable) interword glue
152    /// (`OuterEmpty`). Distinguishes a rigid-but-spaced line (monospace/`+code`
153    /// via `set-space-ratio r 0 0`) from unspaced CJK (which breaks between
154    /// characters, not on glue) — the former MUST wrap rather than overflow.
155    has_glue: bool,
156}
157
158impl LineMetrics {
159    fn empty() -> LineMetrics {
160        LineMetrics {
161            natural: Length::ZERO,
162            stretch: Length::ZERO,
163            shrink: Length::ZERO,
164            has_fil: false,
165            has_glue: false,
166            cjk_natural: Length::ZERO,
167        }
168    }
169
170    fn push(&mut self, bx: &PureHorzBox) {
171        match bx {
172            PureHorzBox::InnerString { width, text, .. } => {
173                self.natural += *width;
174                if text.chars().any(is_cjk) {
175                    self.cjk_natural += *width;
176                }
177            }
178            PureHorzBox::OuterEmpty {
179                natural: n,
180                shrinkable,
181                stretchable,
182            } => {
183                self.natural += *n;
184                self.stretch += *stretchable;
185                self.shrink += *shrinkable;
186                self.has_glue = true;
187            }
188            PureHorzBox::OuterFil => self.has_fil = true,
189            PureHorzBox::FixedEmpty { width } => self.natural += *width,
190            PureHorzBox::Image { width, .. } => self.natural += *width,
191            // A discretionary that does NOT end this line renders its
192            // `no_break` slot (upstream's `get_leftmost/rightmost` no-break
193            // choice) — empty for every UAX#14-only discretionary.
194            PureHorzBox::Discretionary { no_break, .. } => {
195                for b in no_break {
196                    self.natural += b.natural_width();
197                }
198            }
199            PureHorzBox::Graphics { width, .. } => self.natural += *width,
200            // Counted as a fil for width purposes (upstream `Fils(1)`).
201            PureHorzBox::GraphicsOuter { .. } => self.has_fil = true,
202            PureHorzBox::Math { width, .. } => self.natural += *width,
203            // Zero-width marker; fired lang-side after placement.
204            PureHorzBox::HookPageBreak { .. } => {}
205            PureHorzBox::Tabular(tab) => self.natural += tab.width,
206            PureHorzBox::EmbeddedBlock { width, .. } => self.natural += *width,
207            PureHorzBox::Frame { width, .. } => self.natural += *width,
208            PureHorzBox::FrameMarker { .. } => {}
209            // The frame's width/stretch/shrink are whatever its spliced boxes
210            // contribute, so the marker must add nothing or it double-counts.
211            PureHorzBox::InlineFrameMarker { .. } => {}
212            // Zero-width marker; fired to the page bottom by `chop_page`.
213            PureHorzBox::Footnote { .. } => {}
214            PureHorzBox::InlineMark(_) => {}
215        }
216    }
217}
218
219fn measure(line: &[PureHorzBox]) -> LineMetrics {
220    let mut m = LineMetrics::empty();
221    for bx in line {
222        m.push(bx);
223    }
224    m
225}
226
227/// The metrics [`measure`]`(&`[`line_content`]`(pure, start, raw_end))` would
228/// give, computed WITHOUT building that vector.
229///
230/// This is the line breaker's inner loop: the DP measures every candidate line
231/// for every candidate break, and `line_content` clones each box of the span —
232/// including the `String` inside every `InnerString` — purely so `measure` can
233/// walk it. Measured on the corpus that was 20.6M string allocations for
234/// easytable alone (23.1M boxes cloned across 426,795 calls), and the reason
235/// `line-break` accounted for 92 % of that document's evaluation time.
236///
237/// This visits exactly the boxes `line_content` would emit, in exactly that
238/// order, so every floating-point addition happens in the same order and the
239/// metrics are bit-identical — not one break moves.
240fn measure_range(pure: &[PureHorzBox], start: usize, raw_end: usize) -> LineMetrics {
241    let mut m = LineMetrics::empty();
242    if start > 0 {
243        if let PureHorzBox::Discretionary { post_break, .. } = &pure[start - 1] {
244            for b in post_break {
245                m.push(b);
246            }
247        }
248    }
249    for bx in trim_trailing_glue(trim_leading_glue(&pure[start..raw_end])) {
250        if let PureHorzBox::Discretionary { no_break, .. } = bx {
251            for b in no_break {
252                m.push(b);
253            }
254        } else {
255            m.push(bx);
256        }
257    }
258    if raw_end < pure.len() {
259        if let PureHorzBox::Discretionary { pre_break, .. } = &pure[raw_end] {
260            for b in pre_break {
261                m.push(b);
262            }
263        }
264    }
265    m
266}
267
268/// Whether `c` is a CJK glyph — Hiragana, Katakana, CJK ideographs (incl.
269/// Extension A) and CJK symbols/punctuation. The classifier behind
270/// `LineMetrics::cjk_natural`.
271fn is_cjk(c: char) -> bool {
272    matches!(c,
273        '\u{3000}'..='\u{303F}'   // CJK symbols and punctuation (、。「」…)
274        | '\u{3040}'..='\u{309F}' // Hiragana
275        | '\u{30A0}'..='\u{30FF}' // Katakana
276        | '\u{3400}'..='\u{4DBF}' // CJK Unified Ideographs Extension A
277        | '\u{4E00}'..='\u{9FFF}' // CJK Unified Ideographs
278        | '\u{FF00}'..='\u{FFEF}' // Halfwidth and Fullwidth Forms
279    )
280}
281
282/// `get-natural-metrics` (vminst.ml:2020 `PrimitiveGetNaturalMetrics`;
283/// lineBreak.ml's `get_natural_metrics`): `boxes`' width/height/depth as if
284/// laid out on a single unbroken line. A `Discretionary` contributes its
285/// `no_break` slot (the same choice `get_leftmost_script`/
286/// `get_rightmost_script` make in lineBreak.ml — that's what actually
287/// renders when the break isn't taken). Unlike lineBreak.ml, whose depth is
288/// signed (more negative = deeper, combined via `min`) and gets negated
289/// before this primitive returns it, this port's `PureHorzBox` depths are
290/// already non-negative "how far below the baseline" magnitudes (see
291/// hbox.rs), so `depth` is combined via `.max` directly with no sign flip.
292pub fn natural_metrics(boxes: &[HorzBox]) -> (Length, Length, Length) {
293    fn go<'a>(
294        pure: impl IntoIterator<Item = &'a PureHorzBox>,
295        width: &mut Length,
296        height: &mut Length,
297        depth: &mut Length,
298    ) {
299        for bx in pure {
300            match bx {
301                PureHorzBox::InnerString {
302                    width: w,
303                    height: h,
304                    depth: d,
305                    ..
306                } => {
307                    *width += *w;
308                    *height = (*height).max(*h);
309                    *depth = (*depth).max(*d);
310                }
311                PureHorzBox::OuterEmpty { natural, .. } => *width += *natural,
312                PureHorzBox::OuterFil => {}
313                PureHorzBox::FixedEmpty { width: w } => *width += *w,
314                PureHorzBox::Image { width: w, height: h, .. } => {
315                    *width += *w;
316                    *height = (*height).max(*h);
317                }
318                PureHorzBox::Discretionary { no_break, .. } => go(no_break, width, height, depth),
319                PureHorzBox::Graphics {
320                    width: w,
321                    height: h,
322                    depth: d,
323                    ..
324                } => {
325                    *width += *w;
326                    *height = (*height).max(*h);
327                    *depth = (*depth).max(*d);
328                }
329                PureHorzBox::Math {
330                    width: w,
331                    height: h,
332                    depth: d,
333                    ..
334                } => {
335                    *width += *w;
336                    *height = (*height).max(*h);
337                    *depth = (*depth).max(*d);
338                }
339                // Zero width contribution (fil semantics); height/depth
340                // still feed the run's outer metrics.
341                PureHorzBox::GraphicsOuter { height: h, depth: d, .. } => {
342                    *height = (*height).max(*h);
343                    *depth = (*depth).max(*d);
344                }
345                PureHorzBox::HookPageBreak { .. } => {}
346                PureHorzBox::Tabular(tab) => {
347                    *width += tab.width;
348                    *height = (*height).max(tab.height);
349                    *depth = (*depth).max(tab.depth);
350                }
351                PureHorzBox::EmbeddedBlock {
352                    width: w,
353                    height: h,
354                    depth: d,
355                    ..
356                } => {
357                    *width += *w;
358                    *height = (*height).max(*h);
359                    *depth = (*depth).max(*d);
360                }
361                PureHorzBox::Frame {
362                    width: w,
363                    height: h,
364                    depth: d,
365                    ..
366                } => {
367                    *width += *w;
368                    *height = (*height).max(*h);
369                    *depth = (*depth).max(*d);
370                }
371                PureHorzBox::FrameMarker { .. } => {}
372                // Zero width (its contents are spliced siblings), but the
373                // frame's padded vertical extent still feeds the outer metrics.
374                PureHorzBox::InlineFrameMarker { height: h, depth: d, .. } => {
375                    *height = (*height).max(*h);
376                    *depth = (*depth).max(*d);
377                }
378                PureHorzBox::Footnote { .. } => {}
379                PureHorzBox::InlineMark(_) => {}
380            }
381        }
382    }
383    let mut width = Length::ZERO;
384    let mut height = Length::ZERO;
385    let mut depth = Length::ZERO;
386    go(
387        boxes.iter().map(|HorzBox::Pure(p)| p),
388        &mut width,
389        &mut height,
390        &mut depth,
391    );
392    (width, height, depth)
393}
394
395/// `embed-block-breakable`/`embed-block-top`'s box-sizing helper — the block
396/// analog of `natural_metrics`, but SUMMED rather than maxed, since a block's
397/// lines stack vertically. Each `Line` contributes its own `height`/`depth`;
398/// each `Skip` adds its length to `height` only. E.g.
399/// `measure_block(&[Line{h,d}, Skip(s)]) == (h+s, d)`.
400pub fn measure_block(block: &[VertBox]) -> (Length, Length) {
401    let mut height = Length::ZERO;
402    let mut depth = Length::ZERO;
403    for vb in block {
404        match vb {
405            VertBox::Line { height: h, depth: d, .. } => {
406                height += *h;
407                depth += *d;
408            }
409            VertBox::Skip(s) | VertBox::ParagTop(s) | VertBox::FramePad(s) => height += *s,
410            // `clear-page`/`hook-page-break-block`/frame markers contribute
411            // zero height, same as upstream's
412            // `ImVertFixedEmpty(_, Length.zero)`.
413            VertBox::ClearPage
414            | VertBox::HookPageBreak(_)
415            | VertBox::FrameStart(_)
416            | VertBox::FrameEnd(_)
417            | VertBox::ListMark(_) => {}
418        }
419    }
420    (height, depth)
421}
422
423/// Adjustment-ratio badness for one candidate line. The ratio itself is
424/// exactly lineBreak.ml's `calculate_ratios` (lines 510-548): `(target -
425/// natural) / stretch` when underfull, `(target - natural) / shrink` when
426/// overfull, `0` when an `inline-fil` is present and underfull (the
427/// `Fils(nfil)` branch at lines 517-524 always reports ratio `0`). Unlike
428/// lineBreak.ml, we don't classify a ratio beyond `ratio_stretch_limit =
429/// 2.0` / `ratio_shrink_limit = -1.0` (lines 507-508) as categorically
430/// "TooShort"/"TooLong" and cut it off there — those limits exist in
431/// v0.0.6 to decide whether to keep a graph edge at all, which has no
432/// analogue in a DP over every candidate line. Instead badness grows
433/// continuously as `10000 * |r|^3` (lineBreak.ml:986's scale, not TeX's
434/// `100 * r^3`) and saturates at `BADNESS_TOO_LONG`, so a moderately-bad line
435/// (`r = 2`, badness 80000) still scores far better than a catastrophic one.
436fn badness(width: Length, metrics: &LineMetrics) -> f64 {
437    let slack = width - metrics.natural;
438    if slack.0.abs() < 1e-9 {
439        return 0.0;
440    }
441    if slack.is_positive() {
442        // Underfull: needs to stretch.
443        if metrics.has_fil {
444            return 0.0;
445        }
446        if metrics.stretch.is_positive() {
447            let ratio = slack / metrics.stretch;
448            // `<=`, i.e. the limit ITSELF is attainable. Upstream's test is
449            // `ratio_raw >= ratio_stretch_limit -> LBTooShort`
450            // (lineBreak.ml:534), so upstream drops the boundary ratio; the
451            // shrink side below is the mirror image (upstream `<=
452            // ratio_shrink_limit`, lineBreak.ml:545, vs our `<`). Both bounds
453            // are inclusive here, deliberately and symmetrically.
454            //
455            // This is a genuine one-value deviation, kept on evidence rather
456            // than by accident. Real font metrics never land a ratio on 2.0
457            // exactly, so it moves no line in any corpus document; what it does
458            // move is synthetic round-number fixtures, where excluding the
459            // boundary turns an ordinary justified two-word line into a dropped
460            // one and leaves a single overfull line as the only representable
461            // partition (`interior_lines_justify`,
462            // `kp_last_line_with_fil_keeps_natural_spacing` both sit exactly on
463            // it). `wraps_at_glue` documents the boundary case end to end.
464            if ratio <= RATIO_STRETCH_LIMIT {
465                // Within the stretch limit: SATySFi `calculate_badness`
466                // (lineBreak.ml:986), `|ratio|³·10000`.
467                return (10000.0 * ratio.abs().powi(3)).min(BADNESS_TOO_LONG);
468            }
469            // Beyond the stretch limit (`LBTooShort`): SATySFi DROPS such a
470            // line, and so do we.
471            //
472            // A rescue here once scored a CJK-bearing line by its ABSOLUTE
473            // underfullness instead, because the port modelled CJK as rigid
474            // discretionaries with no inter-character glue: such a line had no
475            // stretch of its own, so `LBTooShort` fired for a benign reason
476            // and the DP preferred a drastically SHORT rigid line (finite
477            // cost) over a near-full one (dropped, 1e12) — shredding a
478            // CJK+inline-code paragraph into wildly uneven lines (a 134pt line
479            // before a 442pt one). CJK now carries real `adjacent_space` glue
480            // (`primitives.rs`'s `text_to_boxes`), so the premise is gone, and
481            // keeping the rescue on top of real glue actively HURT: it let the
482            // DP take badly underfull lines cheaply, and `layout_line` then
483            // stretched them to justify, opening ~2pt gaps between adjacent CJK
484            // characters where SATySFi has none. Do not reinstate it.
485            return BADNESS_DROPPED;
486        }
487        // No elastic capacity at all. Upstream's `calculate_ratios` divides the
488        // shortfall by a zero stretch, so the ratio is infinite — always past
489        // `ratio_stretch_limit`, i.e. `LBTooShort`, which gets NO graph edge
490        // (`lineBreak.ml:1014`). Drop it here too.
491        //
492        // A rigid line is NOT normally a problem: the `+code` idiom ends each
493        // line with `inline-fil`, and `has_fil` above already scores those 0.
494        // What this fixes is the line with neither fil NOR glue — scoring it by
495        // absolute underfullness capped at `BADNESS_TOO_LONG` made a
496        // DRASTICALLY short rigid line CHEAPER than a slightly overfull one, so
497        // the breaker took a 108pt line on a 440pt column (badness 42_961)
498        // rather than the near-perfect 434.76pt line sitting right there
499        // (badness 1_144) — latexcmds' `\SATySFi;は\LaTeX;の` line.
500        BADNESS_DROPPED
501    } else {
502        // Overfull: needs to shrink.
503        if metrics.shrink.is_positive() {
504            let ratio = slack / metrics.shrink;
505            if ratio < RATIO_SHRINK_LIMIT {
506                // `LBTooLong` (lineBreak.ml:508), scaled by the overflow — see
507                // `too_long_badness` on why a flat cost is not survivable here.
508                too_long_badness(slack, width)
509            } else {
510                (10000.0 * ratio.abs().powi(3)).min(BADNESS_TOO_LONG)
511            }
512        } else if metrics.has_glue {
513            // Overfull with real interword glue that can't shrink (monospace/
514            // `+code`, `set-space-ratio r 0 0`): breaking BEFORE the word that
515            // doesn't fit is the right call, so this must dominate any
516            // hyphen/line penalty. `no_stretch_badness` (below) scores overflow
517            // as a cube of the overflow FRACTION — near-zero for a modest
518            // overflow — which let the DP cram extra words onto an already
519            // overfull line and run text clean off the page edge (visible
520            // clipping in latexcmds `+code`/`\code`). Force the wrap.
521            //
522            // `BADNESS_TOO_LONG` deliberately: zero shrink means ANY
523            // overflow is past `ratio_shrink_limit`, which is upstream's
524            // `LBTooLong` — scored `badness_for_too_long = 100000`
525            // (`lineBreak.ml:989/1027`). `BADNESS_INF` is only 10_000, i.e.
526            // CHEAPER than a merely mediocre permissible line (ratio 1 scores
527            // 10_000), so it read as "mildly loose" rather than "off the page"
528            // and the DP happily overran the margin.
529            too_long_badness(slack, width)
530        } else {
531            // No breakable glue at all (unspaced CJK) AND overfull: SATySFi's
532            // `ratio_shrink_limit = -1.0` (lineBreak.ml:508) excludes any line
533            // that overflows beyond its shrink capacity — with zero shrink that
534            // is ANY overflow, so the breaker is forced to end the line BEFORE
535            // the char that doesn't fit. The port's continuous
536            // `no_stretch_badness` scored a modest CJK overflow near-zero and
537            // let the DP cram, packing CJK ~0.6 line/page fuller than SATySFi
538            // (easytable 18 vs 19). Force the earlier break — at upstream's
539            // `LBTooLong` cost, see the sibling branch above on why
540            // `BADNESS_INF` is too cheap to mean "overfull".
541            too_long_badness(slack, width)
542        }
543    }
544}
545
546/// Whether a box puts anything on the page. Glue, kerns and the zero-width
547/// markers do not; everything else does.
548fn carries_ink(b: &PureHorzBox) -> bool {
549    !matches!(
550        b,
551        PureHorzBox::OuterEmpty { .. }
552            | PureHorzBox::OuterFil
553            | PureHorzBox::FixedEmpty { .. }
554            | PureHorzBox::FrameMarker { .. }
555            | PureHorzBox::InlineFrameMarker { .. }
556            | PureHorzBox::HookPageBreak { .. }
557            | PureHorzBox::Discretionary { .. }
558    )
559}
560
561/// Whether a candidate line is upstream's `LBTooLong` — overfull past what its
562/// shrink can absorb (`calculate_ratios`, `lineBreak.ml:538-548`). Separate
563/// from [`badness`] because the DP needs the CLASSIFICATION, not just the cost.
564fn is_too_long(width: Length, m: &LineMetrics) -> bool {
565    let slack = width - m.natural;
566    if slack.0 >= 0.0 {
567        return false;
568    }
569    if m.shrink.is_positive() {
570        (slack / m.shrink) < RATIO_SHRINK_LIMIT
571    } else {
572        true
573    }
574}
575
576/// Cost of an overfull line SATySFi would call `LBTooLong`.
577///
578/// Upstream scores these at a FLAT `badness_for_too_long = 100000`
579/// (`lineBreak.ml:989`) and gets away with it because of a structural rule this
580/// DP has no analogue for: from a given start point it adds only the FIRST
581/// too-long edge and then abandons that start entirely (`is_already_too_long` /
582/// `RemovalSet`, `lineBreak.ml:1017-1027`), so a longer overfull line from the
583/// same start is never even evaluated.
584///
585/// Scoring every overfull line the same flat cost here is catastrophic: a line
586/// 675pt past the margin costs exactly what one 4pt past costs, and since the
587/// DP prefers fewer lines (`LINE_PENALTY`, and the fewer-lines tiebreak), it
588/// swallowed an ENTIRE PARAGRAPH into one 1115pt line rather than pay for a
589/// second line — the whole of latexcmds' `もしどうしても…` paragraph ran off the
590/// page edge. Growing the cost with the overflow restores upstream's effective
591/// ordering (the least-overfull option wins) while keeping every line
592/// representable, and stays far below `BADNESS_DROPPED` so any feasible
593/// partition still beats any overfull one.
594fn too_long_badness(slack: Length, width: Length) -> f64 {
595    let overflow = -slack.0;
596    BADNESS_TOO_LONG * (1.0 + (overflow / width.0).max(0.0))
597}
598
599
600/// Fold a break's own penalty into its line's demerits, TeX's classic
601/// formula (TeXbook ch.14): a positive penalty discourages breaking there
602/// (`+ p^2`), a negative one encourages it (`- p^2`), and `<=
603/// FORCED_BREAK_PENALTY` is scored plainly since the DP's `floor` already
604/// guarantees the break is taken regardless of cost. Glue's implicit
605/// penalty is always 0, so this is exactly today's formula whenever no
606/// discretionary is involved.
607fn demerits(b: f64, penalty: i32) -> f64 {
608    // SATySFi's edge weight is LINEAR (`badness + pnltybreak`, lineBreak.ml:1013),
609    // not TeX's squared demerit.
610    let base = LINE_PENALTY + b;
611    if penalty <= FORCED_BREAK_PENALTY {
612        base
613    } else {
614        (base + penalty as f64).max(0.0)
615    }
616}
617
618/// Break a paragraph's boxes into justified lines using Knuth–Plass
619/// dynamic programming over glue and discretionary breakpoints.
620pub fn break_into_lines(ctx: &Context, boxes: Vec<HorzBox>) -> Vec<VertBox> {
621    let pure: Vec<PureHorzBox> = boxes.into_iter().map(|HorzBox::Pure(p)| p).collect();
622    let width = ctx.paragraph_width;
623    let n = pure.len();
624
625    if n == 0 {
626        return Vec::new();
627    }
628
629    // SATySFi's UNREACHABLE fallback (lineBreak.ml:1122-1133). When the whole
630    // paragraph fits on one line at its natural width but has too little
631    // stretch to justify to the column, SATySFi's graph has NO permissible
632    // path to the terminal — every candidate line is `LBTooShort`, which adds
633    // no edge (lineBreak.ml:1015) — so `shortest_path` returns `None` and it
634    // emits a SINGLE natural-width (ragged) line rather than a justified split.
635    //
636    // The port's DP scores rather than drops, so it would instead pick a
637    // pathological word-per-line split: the single full line is over-stretched
638    // (`BADNESS_DROPPED`), while each short one-word piece is merely expensive
639    // (`no_stretch_badness`, bounded), and the pieces' aggregate undercuts the
640    // dropped single line — putting each word on its own line (a raw
641    // `line-break ... ` with no trailing `inline-fil`; every doc-class `+p`
642    // appends one, so real prose and the whole corpus never reach here). Match
643    // SATySFi: if the whole paragraph fits on one line yet that line can't be
644    // justified (`BADNESS_DROPPED`), emit it as one natural ragged line.
645    //
646    // Guards: a paragraph ending in `inline-fil` justifies with badness 0 (the
647    // `has_fil` branch of `badness`) so it never satisfies the condition; and a
648    // paragraph carrying a forced break (mandatory newline) must keep that
649    // break, so it is excluded here.
650    {
651        let whole = line_content(&pure, 0, n);
652        let wm = measure(&whole);
653        let has_forced_break = pure.iter().any(PureHorzBox::is_forced_break);
654        if !has_forced_break && wm.natural <= width && badness(width, &wm) >= BADNESS_DROPPED {
655            return vec![layout_line(ctx, whole, width, true)];
656        }
657    }
658
659    // Legal breakpoints: a glue-or-discretionary box (`is_break_point`)
660    // immediately following a box that isn't one (never at the very start
661    // of a line — leading glue after a break is dropped). For each such box
662    // at index `g`, a line ending there spans up to (excluding) `g`, and the
663    // next line starts at `g + 1` (the box itself is discarded, as glue is).
664    // A run of several adjacent break candidates collapses to just its
665    // first: the trim helpers below eat whatever of the run leaks into a
666    // line's edges either way, so this loses no representable line, only
667    // redundant DP states. The end of the paragraph is always a forced
668    // final breakpoint too.
669    //
670    // `nodes[k] = (line_end_excl, next_line_start)`; node 0 is the
671    // virtual start of the paragraph.
672    let mut starts: Vec<usize> = vec![0];
673    let mut ends: Vec<usize> = Vec::new();
674    // Index of the last box that actually marks the page (see the `g > last_ink`
675    // guard below).
676    let last_ink = pure.iter().rposition(carries_ink).unwrap_or(0);
677    for g in 1..n {
678        // `is_break_point`, not a bare `matches!`: a `NO_BREAK_PENALTY`
679        // discretionary is upstream's `LBPure(glue)` (`convertText.ml:190`) and
680        // must not become a candidate through the `|| is_disc` clause below.
681        let is_disc =
682            matches!(pure[g], PureHorzBox::Discretionary { .. }) && pure[g].is_break_point();
683        // A break candidate is the FIRST box of a run of break points
684        // (`is_break_point && !prev-is-break-point`) — breaking at glue
685        // discards that glue. BUT a `Discretionary` is ALSO a candidate even
686        // when it immediately follows glue: unlike glue, breaking at a
687        // discretionary does NOT discard the glue *before* it. This is exactly
688        // what the `+code` idiom `text ++ inline-fil ++ discretionary` needs —
689        // the line must keep its trailing `inline-fil` (to justify) and break
690        // at the discretionary. Without the discretionary as its own candidate
691        // the run `[fil, disc]` collapsed to the `fil`, so the break discarded
692        // the fil, leaving an underfull line the DP then declined to break —
693        // merging code lines and shoving them off-page via the discretionary's
694        // 2×width no-break skip (whole code blocks rendered a few lines).
695        // A break with NO INK after it is not a real alternative — it just
696        // moves the paragraph's trailing glue onto a blank line. The terminal
697        // break below already covers "end the paragraph here", so offering
698        // these as well let the breaker split `[word, fil]` into an overfull
699        // line PLUS an empty one once the one-overfull-edge rule (below) made
700        // the single-line option unreachable.
701        if g > last_ink {
702            continue;
703        }
704        if (pure[g].is_break_point() && !pure[g - 1].is_break_point()) || is_disc {
705            ends.push(g);
706            starts.push(g + 1);
707        }
708    }
709    ends.push(n); // forced final break; has no "next start".
710
711    let m = ends.len();
712    // dp[k] = (best total demerits, line count) to reach node k (k in
713    // 0..=m, where node k>0 means "paragraph broken through ends[k-1]").
714    const EPS: f64 = 1e-6;
715    let mut dp: Vec<(f64, usize)> = vec![(f64::INFINITY, usize::MAX); m + 1];
716    let mut back: Vec<usize> = vec![usize::MAX; m + 1];
717    dp[0] = (0.0, 0);
718
719    // Ratchets forward past a forced break (a discretionary scoring
720    // `is_forced_break`, e.g. a UAX#14 `Mandatory` newline): once `j`
721    // passes one, no later line may span back over it, which is exactly
722    // "the breaker must end a line here" for a DP over every candidate
723    // line rather than a graph search. `dp[floor]` is always finite when
724    // this ratchets (it only ever advances to a `j` just computed above),
725    // so no later `dp[j]` can get stuck at infinity.
726    let mut floor: usize = 0;
727
728    // Upstream adds at most ONE `LBTooLong` edge per source node and then drops
729    // that node entirely (`is_already_too_long` / `RemovalSet`,
730    // `lineBreak.ml:1017-1027`). Because destinations are visited in order of
731    // increasing line width, the one edge it keeps is the LEAST overfull.
732    //
733    // That rule is structural, and no per-line COST can stand in for it: an
734    // overfull line is worth ~`BADNESS_TOO_LONG` whatever its overflow, so a
735    // partition with fewer overfull lines always wins, however badly each one
736    // overruns. latexcmds' `+code` block was set as 3 lines ending at 589.0 /
737    // 544.9 / 513.4 on a column ending at 515 — one line 74pt past the margin —
738    // where SATySFi takes 4 lines at 519.7 / 526.0 / 519.7, each only a few
739    // points over. Same for the paragraph that got swallowed into a single
740    // 1115pt line.
741    //
742    // `j` ascends, so the first overfull `(i, j)` we meet for a given start `i`
743    // is that start's least-overfull option; every later one is unreachable.
744    let mut spent_overfull: Vec<bool> = vec![false; m + 1];
745
746    for j in 1..=m {
747        let raw_end = ends[j - 1];
748        let penalty = if raw_end < n {
749            pure[raw_end].break_penalty()
750        } else {
751            0
752        };
753        // `i` is scanned from the closest (tightest line) backward, so an
754        // earlier `i` only ever makes a candidate line wider — which is what
755        // makes the width short-circuit at the end of this loop safe.
756        for i in (floor..j).rev() {
757            if dp[i].0.is_infinite() {
758                continue;
759            }
760            let start = starts[i];
761            if start > raw_end {
762                // Can't happen (starts/ends interleave), but guard anyway.
763                continue;
764            }
765            let mut metrics = measure_range(&pure, start, raw_end);
766            // The break itself, if it's a chosen discretionary, carries
767            // `pre_break` onto the CLOSED line (the hyphen/etc. that
768            // actually prints before the break) — hyphenation;
769            // empty for a UAX#14-only discretionary, so a no-op then.
770            if raw_end < n {
771                if let PureHorzBox::Discretionary { pre_break, .. } = &pure[raw_end] {
772                    for b in pre_break {
773                        metrics.natural += b.natural_width();
774                    }
775                }
776            }
777            if is_too_long(width, &metrics) {
778                if spent_overfull[i] {
779                    continue; // this start already used its single overfull edge
780                }
781                spent_overfull[i] = true;
782            }
783            let b = badness(width, &metrics);
784            let d = demerits(b, penalty);
785            let cand_cost = dp[i].0 + d;
786            let cand_lines = dp[i].1 + 1;
787            if cand_cost < dp[j].0 - EPS
788                || ((cand_cost - dp[j].0).abs() <= EPS && cand_lines < dp[j].1)
789            {
790                dp[j] = (cand_cost, cand_lines);
791                back[j] = i;
792            }
793            // Near-linear short-circuit: if this line is already massively
794            // overfull (natural width more than double the target beyond
795            // what any shrink could fix) and we're not at the very first
796            // (tightest) candidate for this `j`, earlier `i` only grows
797            // the line further, so stop scanning backward.
798            if i + 1 != j && metrics.natural.0 > width.0 * 4.0 + 1.0 {
799                break;
800            }
801        }
802        if raw_end < n && pure[raw_end].is_forced_break() {
803            floor = j;
804        }
805    }
806
807    // Reconstruct the chosen breakpoints.
808    let mut line_ranges: Vec<(usize, usize)> = Vec::new();
809    let mut j = m;
810    while j > 0 {
811        let i = back[j];
812        debug_assert_ne!(i, usize::MAX, "no path found to breakpoint {j}");
813        line_ranges.push((starts[i], ends[j - 1]));
814        j = i;
815    }
816    line_ranges.reverse();
817
818    let line_count = line_ranges.len();
819    line_ranges
820        .into_iter()
821        .enumerate()
822        .flat_map(|(idx, (start, raw_end))| {
823            let content = line_content(&pure, start, raw_end);
824            // `LBEmbeddedVertBreakable` (`lineBreak.ml:809-818`): a breakable
825            // embedded block is NOT laid out as a line. Upstream flushes the
826            // line accumulated so far, splices the block's own vertical boxes
827            // into the vertical list as `AlreadyVert`, then starts fresh — the
828            // block's vertical extent IS the gap, with no line leading of its
829            // own. `prim_embed_block_breakable` fences each such block between
830            // forced breaks, so it always lands alone on its own "line" here,
831            // which is exactly the segment to splice.
832            //
833            // Wrapping it in a `layout_line` instead gave it a full leading on
834            // top of its own height: latexcmds' `\linebreak` (whose block is a
835            // `block-skip` of `leading - font_size`) then advanced 36.0pt where
836            // SATySFi advances ~15.5pt — double-spacing every hard-broken line.
837            if let Some(block) = sole_breakable_block(&content) {
838                return block;
839            }
840            vec![layout_line(ctx, content, width, idx + 1 == line_count)]
841        })
842        .collect()
843}
844
845/// Trailing glue-or-discretionary never justifies anything and is dropped
846/// from a line, except a trailing `OuterFil` (which is how a paragraph's
847/// final stretch is represented, and must stay so the last line can absorb
848/// slack without being force-justified). Only the *last* line's raw range
849/// can have one of these at its tail in the first place — see the
850/// breakpoint-collapsing comment in `break_into_lines`.
851///
852/// A `NO_BREAK_PENALTY` discretionary is exempt: it is not a breakpoint at all
853/// but upstream's `LBPure(glue)`, which is never discardable.
854fn trim_trailing_glue(line: &[PureHorzBox]) -> &[PureHorzBox] {
855    let mut end = line.len();
856    while end > 0 {
857        match &line[end - 1] {
858            PureHorzBox::OuterEmpty { .. } => end -= 1,
859            b @ PureHorzBox::Discretionary { .. } if b.is_break_point() => end -= 1,
860            _ => break,
861        }
862    }
863    &line[..end]
864}
865
866/// A break never leaves discardable glue or an unchosen discretionary at the
867/// very start of the next line either (the old greedy dropped any glue seen
868/// while `current` was still empty); drop it here so a pathological run of
869/// consecutive break-point boxes doesn't get counted as this line's content.
870///
871/// A leading `OuterFil` is explicitly NOT dropped — it is not discardable
872/// inter-word glue but user-inserted fill (`inline-fil`), the left half of the
873/// `inline-fil ++ ib ++ inline-fil` centering / `... ++ inline-fil` (right
874/// half) `ib ++ inline-fil`-flush idiom (e.g. stdjareport's centered title
875/// block). Dropping it here silently collapsed every centered/right-flushed
876/// line to the left margin. `trim_trailing_glue` already keeps a trailing
877/// `OuterFil` for the same reason; this is the symmetric leading case.
878/// `NO_BREAK_PENALTY` discretionaries are exempt here too, for the reason given
879/// on `trim_trailing_glue`.
880fn trim_leading_glue(line: &[PureHorzBox]) -> &[PureHorzBox] {
881    let mut start = 0;
882    while start < line.len()
883        && match &line[start] {
884            PureHorzBox::OuterEmpty { .. } => true,
885            b @ PureHorzBox::Discretionary { .. } => b.is_break_point(),
886            _ => false,
887        }
888    {
889        start += 1;
890    }
891    &line[start..]
892}
893
894/// The actual content of a line spanning `pure[start..raw_end)`, with
895/// leading and trailing glue trimmed, and every `Discretionary` resolved
896/// to what actually renders on this line (hyphenation — `linebreak.rs`
897/// module doc, "the first filler"):
898/// - a discretionary the line does NOT end on renders its `no_break` slot
899///   (spliced in place, matching `measure`'s treatment of one that survives
900///   into a line's interior — shouldn't normally happen since discretionaries
901///   are break candidates, but a run of several collapses to just the
902///   first, see `break_into_lines`'s comment, so later ones in the run can
903///   land here as ordinary un-taken candidates);
904/// - the break this line WAS chosen to end on (`pure[raw_end]`, only when
905///   `raw_end < pure.len()`) contributes its `pre_break` slot at the line's
906///   end (the hyphen prints here);
907/// - the break the PREVIOUS line was chosen to end on (`pure[start - 1]`,
908///   only when `start > 0`) contributes its `post_break` slot at this
909///   line's start (continuation text after the hyphen).
910/// Every slot is empty for a UAX#14-only discretionary, so this is
911/// behavior-identical to the old borrow-only version until hyphenation fills
912/// them.
913fn line_content(pure: &[PureHorzBox], start: usize, raw_end: usize) -> Vec<PureHorzBox> {
914    let mut out = Vec::new();
915    if start > 0 {
916        if let PureHorzBox::Discretionary { post_break, .. } = &pure[start - 1] {
917            out.extend(post_break.iter().cloned());
918        }
919    }
920    for bx in trim_trailing_glue(trim_leading_glue(&pure[start..raw_end])) {
921        if let PureHorzBox::Discretionary { no_break, .. } = bx {
922            out.extend(no_break.iter().cloned());
923        } else {
924            out.push(bx.clone());
925        }
926    }
927    if raw_end < pure.len() {
928        if let PureHorzBox::Discretionary { pre_break, .. } = &pure[raw_end] {
929            if !pre_break.is_empty() {
930                // Mark what follows as the BREAKER's, not the author's — see
931                // `InlineMarkKind::BreakHyphen`.
932                out.push(PureHorzBox::InlineMark(InlineMarkKind::BreakHyphen));
933            }
934            out.extend(pre_break.iter().cloned());
935        }
936    }
937    out
938}
939
940/// The inner vertical boxes of a line that holds NOTHING but one breakable
941/// embedded block (plus inert zero-width markers and glue), or `None`.
942/// See its caller in [`break_into_lines`].
943fn sole_breakable_block(content: &[PureHorzBox]) -> Option<Vec<VertBox>> {
944    let mut found: Option<&Vec<VertBox>> = None;
945    for bx in content {
946        match bx {
947            PureHorzBox::EmbeddedBlock {
948                block,
949                breakable: true,
950                ..
951            } => {
952                if found.is_some() {
953                    return None; // two blocks: lay the line out normally
954                }
955                found = Some(block);
956            }
957            // Inert: carries no ink and no width of its own.
958            PureHorzBox::FrameMarker { .. }
959            | PureHorzBox::InlineFrameMarker { .. }
960            | PureHorzBox::HookPageBreak { .. }
961            | PureHorzBox::OuterEmpty { .. }
962            | PureHorzBox::OuterFil
963            | PureHorzBox::FixedEmpty { .. } => {}
964            _ => return None,
965        }
966    }
967    found.cloned()
968}
969
970/// Assign x offsets, justifying interior lines by distributing slack into
971/// glue (`OuterFil` absorbs all positive slack; otherwise stretchables or
972/// shrinkables share it proportionally). The last line stays ragged: it is
973/// never force-*stretched* to fill the width, but it is still *shrunk* if
974/// overfull, since shrink represents real interword compressibility, not
975/// justification.
976fn layout_line(ctx: &Context, line: Vec<PureHorzBox>, width: Length, is_last: bool) -> VertBox {
977    let (contents, height, depth) = justify_line(line, width, is_last);
978    // An all-glue line (e.g. `line-break ctx inline-fil`, used as a pure
979    // spacer with its own paragraph-margin skip) draws nothing and occupies
980    // zero vertical extent — no strut.
981    VertBox::Line {
982        height,
983        depth,
984        leading: ctx.leading,
985        contents,
986    }
987}
988
989/// `LineBreak.fit hblstwithpads wid` (tabular.ml:270/287) — fit `content`
990/// (already padding-wrapped by the caller, `tabular::solidify_tabular`) to
991/// exactly `width`, distributing slack into glue/`inline-fil` exactly as
992/// `justify_line` does for an ordinary paragraph line (so `inline-fil ++ …
993/// ++ inline-fil` centers a cell). Unlike `layout_line`, this takes no
994/// `Context`: a table cell has no font-size fallback to lean on (the grid
995/// solver never threads one, matching upstream's `BackendTabular`), so
996/// height/depth come from `natural_metrics` instead of the all-glue
997/// fallback. Always justifies as an interior (non-final) line — a cell's
998/// content is never "ragged" the way a paragraph's last line is.
999pub fn fit_cell(content: Vec<HorzBox>, width: Length) -> (Vec<(Length, PureHorzBox)>, Length, Length) {
1000    let (_, height, depth) = natural_metrics(&content);
1001    let pure: Vec<PureHorzBox> = content.into_iter().map(|HorzBox::Pure(p)| p).collect();
1002    let (contents, _, _) = justify_line(pure, width, false);
1003    (contents, height, depth)
1004}
1005
1006/// The shared position-assignment core of `layout_line`/`fit_cell`: returns
1007/// each box's `x` offset alongside the line's own height/depth (computed
1008/// the same way `layout_line` always has — callers needing a `Context`-based
1009/// all-glue fallback apply it on top, see `layout_line` above).
1010fn justify_line(
1011    line: Vec<PureHorzBox>,
1012    width: Length,
1013    is_last: bool,
1014) -> (Vec<(Length, PureHorzBox)>, Length, Length) {
1015    let natural: Length = line
1016        .iter()
1017        .map(|b| b.natural_width())
1018        .fold(Length::ZERO, |acc, w| acc + w);
1019    let slack = width - natural;
1020
1021    let fil_count = line
1022        .iter()
1023        .filter(|b| matches!(b, PureHorzBox::OuterFil | PureHorzBox::GraphicsOuter { .. }))
1024        .count();
1025    let stretch_total: Length = line
1026        .iter()
1027        .map(|b| match b {
1028            PureHorzBox::OuterEmpty { stretchable, .. } => *stretchable,
1029            _ => Length::ZERO,
1030        })
1031        .fold(Length::ZERO, |acc, w| acc + w);
1032    let shrink_total: Length = line
1033        .iter()
1034        .map(|b| match b {
1035            PureHorzBox::OuterEmpty { shrinkable, .. } => *shrinkable,
1036            _ => Length::ZERO,
1037        })
1038        .fold(Length::ZERO, |acc, w| acc + w);
1039    // Clamp the shrink ratio at -1 (full collapse): don't let glue widths
1040    // go negative when a line is overfull beyond its shrink capacity —
1041    // mirrors lineBreak.ml's `LBTooLong` case, which subtracts each box's
1042    // full `shrinkable` rather than over-shrinking past it
1043    // (lineBreak.ml:588-590).
1044    let shrink_ratio = if slack.is_positive() || !shrink_total.is_positive() {
1045        0.0
1046    } else {
1047        (slack / shrink_total).max(-1.0)
1048    };
1049
1050    let mut x = Length::ZERO;
1051    let mut contents = Vec::with_capacity(line.len());
1052    let mut height = Length::ZERO;
1053    let mut depth = Length::ZERO;
1054
1055    for mut bx in line {
1056        let advance = match &mut bx {
1057            PureHorzBox::InnerString {
1058                width,
1059                height: h,
1060                depth: d,
1061                ..
1062            } => {
1063                height = height.max(*h);
1064                depth = depth.max(*d);
1065                *width
1066            }
1067            PureHorzBox::OuterEmpty {
1068                natural,
1069                shrinkable,
1070                stretchable,
1071            } => {
1072                let mut adv = *natural;
1073                if slack.is_positive() {
1074                    if fil_count == 0 && stretch_total.is_positive() && !is_last {
1075                        adv += slack * (*stretchable / stretch_total);
1076                    }
1077                } else if shrink_ratio != 0.0 {
1078                    adv += *shrinkable * shrink_ratio;
1079                }
1080                adv
1081            }
1082            PureHorzBox::OuterFil => {
1083                if fil_count > 0 && slack.is_positive() {
1084                    slack * (1.0 / fil_count as f64)
1085                } else {
1086                    Length::ZERO
1087                }
1088            }
1089            PureHorzBox::FixedEmpty { width } => *width,
1090            PureHorzBox::Image { width, height: h, .. } => {
1091                height = height.max(*h);
1092                // An image sits entirely on the baseline: it contributes to
1093                // the line's height but never its depth. `depth` only ever
1094                // grows via `.max` and starts at `ZERO`, so this is a no-op
1095                // today — kept explicit so the "images have zero depth"
1096                // decision reads as deliberate rather than an omission if
1097                // `depth` ever gains a different starting point.
1098                depth = depth.max(Length::ZERO);
1099                *width
1100            }
1101            // Not chosen as this line's break (it would have been excluded
1102            // from `line` entirely otherwise, see `line_content`), so it
1103            // renders as `no_break` — empty for a UAX#14-only discretionary,
1104            // hence zero-width.
1105            PureHorzBox::Discretionary { .. } => Length::ZERO,
1106            PureHorzBox::Graphics {
1107                width,
1108                height: h,
1109                depth: d,
1110                ..
1111            } => {
1112                height = height.max(*h);
1113                depth = depth.max(*d);
1114                *width
1115            }
1116            // `inline-graphics-outer`: shares slack equally with real fils
1117            // (upstream `Fils(nfil)` counts both, `fil_count` above), and
1118            // WRITES the resolved per-fil share back into the box — read
1119            // back by the lang-side post-pass (`resolve_outer_graphics_in_
1120            // contents`, rustyfi-lang's primitives) once this line is done.
1121            PureHorzBox::GraphicsOuter {
1122                height: h,
1123                depth: d,
1124                width: w,
1125                ..
1126            } => {
1127                height = height.max(*h);
1128                depth = depth.max(*d);
1129                let adv = if fil_count > 0 && slack.is_positive() {
1130                    slack * (1.0 / fil_count as f64)
1131                } else {
1132                    Length::ZERO
1133                };
1134                *w = adv;
1135                adv
1136            }
1137            // Unlike `Image` (all height, zero depth), a math run grows
1138            // *both* line dimensions: a superscript raises `height`, a
1139            // subscript deepens `depth`.
1140            PureHorzBox::Math {
1141                width,
1142                height: h,
1143                depth: d,
1144                ..
1145            } => {
1146                height = height.max(*h);
1147                depth = depth.max(*d);
1148                *width
1149            }
1150            // Zero-width, zero-height, zero-depth marker (`is_glue ==
1151            // false`, like `Image`/`FixedEmpty`); fired lang-side, after
1152            // placement, by `fire_hooks`.
1153            PureHorzBox::HookPageBreak { .. } => Length::ZERO,
1154            PureHorzBox::Tabular(tab) => {
1155                height = height.max(tab.height);
1156                depth = depth.max(tab.depth);
1157                tab.width
1158            }
1159            // `embed-block-top`/`embed-block-breakable`'s carried block
1160            // (rows 7-8): same height/depth-driving shape as
1161            // `Graphics`/`Tabular` above.
1162            PureHorzBox::EmbeddedBlock {
1163                width,
1164                height: h,
1165                depth: d,
1166                ..
1167            } => {
1168                height = height.max(*h);
1169                depth = depth.max(*d);
1170                *width
1171            }
1172            // An inline frame is exactly as "tall" as it reports (padding
1173            // already folded into `height`/`depth` by `make_inline_frame`),
1174            // same height/depth-driving shape as `Graphics`/`Tabular` above.
1175            PureHorzBox::Frame {
1176                width,
1177                height: h,
1178                depth: d,
1179                ..
1180            } => {
1181                height = height.max(*h);
1182                depth = depth.max(*d);
1183                *width
1184            }
1185            // Zero-width marker; read back by `fire_hooks` after placement.
1186            PureHorzBox::FrameMarker { .. } => Length::ZERO,
1187            // Zero-WIDTH bracket whose contents are spliced siblings on this
1188            // same line, so it advances nothing — but it does carry the
1189            // frame's padded vertical extent (see the variant's doc comment),
1190            // which is how `paddingT`/`paddingB` reach the line's height and
1191            // depth now that the frame is no longer one atomic box.
1192            PureHorzBox::InlineFrameMarker { height: h, depth: d, .. } => {
1193                height = height.max(*h);
1194                depth = depth.max(*d);
1195                Length::ZERO
1196            }
1197            // Zero-width marker; extracted and bottom-placed by `chop_page`
1198            // at page-commit time.
1199            PureHorzBox::Footnote { .. } => Length::ZERO,
1200            // Zero-width marker; read only by the reflow HTML walker.
1201            PureHorzBox::InlineMark(_) => Length::ZERO,
1202        };
1203        contents.push((x, bx));
1204        x += advance;
1205    }
1206
1207    (contents, height, depth)
1208}