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}