use std::collections::HashMap;
use crate::layout::greedy::{self, Atom, AtomGlyph, TextAtom};
use crate::linebreak::LineBreakParams;
use crate::metrics_keys;
use crate::model::{FontSpec, Paragraph};
use crate::shape::LineShaper;
use super::hyphenate;
use super::Hyphenation;
const NO_GLUE_BADNESS: f64 = 1.0e12;
const SHRINK_FEASIBILITY_EPSILON: f64 = 1e-6;
fn badness(natural: f64, target: f64, stretch: f64, shrink: f64) -> f64 {
if !target.is_finite() {
return 0.0;
}
let diff = target - natural;
if diff.abs() < 1e-9 {
return 0.0;
}
if diff > 0.0 {
if stretch <= 0.0 {
NO_GLUE_BADNESS
} else {
100.0 * (diff / stretch).powi(3)
}
} else {
let need = -diff;
if shrink <= 0.0 {
NO_GLUE_BADNESS
} else {
100.0 * (need / shrink).powi(3)
}
}
}
fn last_line_badness(natural: f64, target: f64, shrink: f64) -> f64 {
if !target.is_finite() {
return 0.0;
}
if natural <= target {
0.0
} else {
badness(natural, target, 0.0, shrink)
}
}
fn badness_strict(natural: f64, target: f64, stretch: f64, shrink: f64) -> f64 {
if !target.is_finite() {
return 0.0;
}
let diff = target - natural;
if diff.abs() < 1e-9 {
return 0.0;
}
if diff > 0.0 {
if stretch <= 0.0 {
NO_GLUE_BADNESS
} else {
100.0 * (diff / stretch).powi(3)
}
} else {
let need = -diff;
if need > shrink + SHRINK_FEASIBILITY_EPSILON {
f64::INFINITY
} else if shrink <= 0.0 {
NO_GLUE_BADNESS
} else {
100.0 * (need / shrink).powi(3)
}
}
}
fn last_line_badness_strict(natural: f64, target: f64, shrink: f64) -> f64 {
if !target.is_finite() {
return 0.0;
}
if natural <= target {
0.0
} else {
badness_strict(natural, target, 0.0, shrink)
}
}
fn demerits(badness: f64, penalty: f64) -> f64 {
(1.0 + badness + penalty.max(0.0)).powi(2)
}
fn trim_trailing_discardables(atoms: &[Atom]) -> &[Atom] {
let mut end = atoms.len();
while end > 0 {
match &atoms[end - 1] {
Atom::Text(t) if t.is_glue => end -= 1,
Atom::Break => end -= 1,
_ => break,
}
}
&atoms[..end]
}
fn span_metrics(atoms: &[Atom], hyphen_width: f64, ends_in_hyphen: bool, params: LineBreakParams) -> (f64, f64, f64) {
let trimmed = trim_trailing_discardables(atoms);
let mut width = 0.0_f64;
let mut stretch = 0.0_f64;
let mut shrink = 0.0_f64;
for atom in trimmed {
width += greedy::atom_width(atom);
if let Atom::Text(t) = atom {
if t.is_glue {
stretch += t.width * params.glue_stretch_ratio;
shrink += t.width * params.glue_shrink_ratio;
}
}
}
if ends_in_hyphen {
width += hyphen_width;
}
(width, stretch, shrink)
}
fn legal_breaks(atoms: &[Atom]) -> Vec<usize> {
let mut points = vec![0usize];
for c in 1..atoms.len() {
let legal = match &atoms[c - 1] {
Atom::Text(t) if t.is_glue => !matches!(&atoms[c], Atom::Text(t2) if t2.is_glue),
Atom::Text(t) if t.hyphen_break => true,
Atom::Break => true,
_ => false,
};
if legal {
points.push(c);
}
}
points.push(atoms.len());
points
}
fn spans_a_break(atoms: &[Atom], start: usize, end: usize) -> bool {
end > start + 1 && atoms[start..end - 1].iter().any(|a| matches!(a, Atom::Break))
}
fn run_index_of(atom: &Atom) -> usize {
match atom {
Atom::Text(t) => t.run_index,
_ => 0,
}
}
struct HyphenCache<'a> {
paragraph: &'a Paragraph<'a>,
shaper: &'a dyn LineShaper,
cache: HashMap<usize, (AtomGlyph, f64, FontSpec)>,
}
impl<'a> HyphenCache<'a> {
fn new(paragraph: &'a Paragraph<'a>, shaper: &'a dyn LineShaper) -> Self {
Self { paragraph, shaper, cache: HashMap::new() }
}
fn get(&mut self, run_index: usize) -> (AtomGlyph, f64, FontSpec) {
if let Some(entry) = self.cache.get(&run_index) {
return entry.clone();
}
let (font, vertical_align) = self
.paragraph
.runs
.get(run_index)
.map(|r| (r.font, r.vertical_align))
.unwrap_or_default();
let shape_font = vertical_align.shape_font(font);
let (glyph, advance) = shape_hyphen(&shape_font, self.shaper);
let entry = (glyph, advance, shape_font);
self.cache.insert(run_index, entry.clone());
entry
}
}
fn shape_hyphen(font: &FontSpec, shaper: &dyn LineShaper) -> (AtomGlyph, f64) {
let lines = shaper.shape_wrapped("-", font, f64::MAX);
if let Some(g) = lines.first().and_then(|l| l.glyphs.first()) {
let glyph =
AtomGlyph { cluster: g.cluster.clone(), x: 0.0, y_offset: g.y_offset, advance: g.advance, width: g.width };
let advance = g.advance;
(glyph, advance)
} else {
(AtomGlyph { cluster: "-".to_string(), x: 0.0, y_offset: 0.0, advance: 0.0, width: 0.0 }, 0.0)
}
}
pub(crate) fn pack_lines(atoms: Vec<Atom>, paragraph: &Paragraph<'_>, shaper: &dyn LineShaper) -> Vec<Vec<Atom>> {
if atoms.is_empty() {
return Vec::new();
}
let params = paragraph.line_break_params;
let atoms = if paragraph.hyphenation != Hyphenation::None {
hyphenate::expand_hyphenation(atoms, paragraph.hyphenation, params.left_min, params.right_min)
} else {
atoms
};
match paragraph.max_consecutive_hyphens {
Some(limit) => pack_lines_with_hyphen_limit(atoms, paragraph, shaper, limit),
None => pack_lines_unconstrained(atoms, paragraph, shaper),
}
}
type LineBadnessFn = fn(f64, f64, f64, f64) -> f64;
type LastLineBadnessFn = fn(f64, f64, f64) -> f64;
fn run_unconstrained_dp(
atoms: &[Atom],
candidates: &[usize],
max_width: f64,
params: LineBreakParams,
hyphens: &mut HyphenCache<'_>,
line_badness: LineBadnessFn,
last_badness: LastLineBadnessFn,
) -> Option<Vec<usize>> {
let n = candidates.len();
let mut best = vec![f64::INFINITY; n];
let mut via = vec![0usize; n];
let mut via_hyphen = vec![false; n];
best[0] = 0.0;
for j in 1..n {
let c_j = candidates[j];
let is_final = c_j == atoms.len();
for i in 0..j {
if !best[i].is_finite() {
continue;
}
let c_i = candidates[i];
if spans_a_break(atoms, c_i, c_j) {
continue;
}
let slice = &atoms[c_i..c_j];
let ends_in_hyphen = matches!(slice.last(), Some(Atom::Text(t)) if t.hyphen_break);
let hyphen_width =
if ends_in_hyphen { hyphens.get(run_index_of(&slice[slice.len() - 1])).1 } else { 0.0 };
let (width, stretch, shrink) = span_metrics(slice, hyphen_width, ends_in_hyphen, params);
let b = if is_final { last_badness(width, max_width, shrink) } else { line_badness(width, max_width, stretch, shrink) };
if !b.is_finite() {
continue; }
let penalty = if ends_in_hyphen { params.hyphen_penalty } else { 0.0 };
let mut d = demerits(b, penalty);
if ends_in_hyphen && via_hyphen[i] {
d += params.double_hyphen_demerit;
}
let total = best[i] + d;
if total < best[j] {
best[j] = total;
via[j] = i;
via_hyphen[j] = ends_in_hyphen;
}
}
}
if !best[n - 1].is_finite() {
return None;
}
let mut path = vec![n - 1];
let mut cur = n - 1;
while cur != 0 {
cur = via[cur];
path.push(cur);
}
path.reverse();
Some(path)
}
fn pack_lines_unconstrained(atoms: Vec<Atom>, paragraph: &Paragraph<'_>, shaper: &dyn LineShaper) -> Vec<Vec<Atom>> {
let max_width = if paragraph.max_width.is_finite() { paragraph.max_width.max(1.0) } else { f64::MAX };
let params = paragraph.line_break_params;
let candidates = legal_breaks(&atoms);
let mut hyphens = HyphenCache::new(paragraph, shaper);
if let Some(path) =
run_unconstrained_dp(&atoms, &candidates, max_width, params, &mut hyphens, badness_strict, last_line_badness_strict)
{
return reconstruct_lines(&atoms, &candidates, &path, &mut hyphens);
}
metrics::counter!(metrics_keys::KEY_LINEBREAK_OVERFULL_FALLBACK).increment(1);
match run_unconstrained_dp(&atoms, &candidates, max_width, params, &mut hyphens, badness, last_line_badness) {
Some(path) => reconstruct_lines(&atoms, &candidates, &path, &mut hyphens),
None => {
vec![trim_trailing_discardables(&atoms).to_vec()]
}
}
}
fn pack_lines_with_hyphen_limit(atoms: Vec<Atom>, paragraph: &Paragraph<'_>, shaper: &dyn LineShaper, limit: u8) -> Vec<Vec<Atom>> {
let max_width = if paragraph.max_width.is_finite() { paragraph.max_width.max(1.0) } else { f64::MAX };
let params = paragraph.line_break_params;
let candidates = legal_breaks(&atoms);
let mut hyphens = HyphenCache::new(paragraph, shaper);
if let Some(path) =
run_hyphen_limited_dp(&atoms, &candidates, max_width, params, &mut hyphens, limit, badness_strict, last_line_badness_strict)
{
return reconstruct_lines(&atoms, &candidates, &path, &mut hyphens);
}
metrics::counter!(metrics_keys::KEY_LINEBREAK_OVERFULL_FALLBACK).increment(1);
match run_hyphen_limited_dp(&atoms, &candidates, max_width, params, &mut hyphens, limit, badness, last_line_badness) {
Some(path) => reconstruct_lines(&atoms, &candidates, &path, &mut hyphens),
None => {
vec![trim_trailing_discardables(&atoms).to_vec()]
}
}
}
fn run_hyphen_limited_dp(
atoms: &[Atom],
candidates: &[usize],
max_width: f64,
params: LineBreakParams,
hyphens: &mut HyphenCache<'_>,
limit: u8,
line_badness: LineBadnessFn,
last_badness: LastLineBadnessFn,
) -> Option<Vec<usize>> {
let n = candidates.len();
let run_states = limit as usize + 1;
let mut best = vec![vec![f64::INFINITY; run_states]; n];
let mut via = vec![vec![(0usize, 0usize); run_states]; n];
best[0][0] = 0.0;
for j in 1..n {
let c_j = candidates[j];
let is_final = c_j == atoms.len();
for i in 0..j {
let c_i = candidates[i];
if spans_a_break(atoms, c_i, c_j) {
continue;
}
let slice = &atoms[c_i..c_j];
let ends_in_hyphen = matches!(slice.last(), Some(Atom::Text(t)) if t.hyphen_break);
let hyphen_width =
if ends_in_hyphen { hyphens.get(run_index_of(&slice[slice.len() - 1])).1 } else { 0.0 };
let (width, stretch, shrink) = span_metrics(slice, hyphen_width, ends_in_hyphen, params);
let b = if is_final { last_badness(width, max_width, shrink) } else { line_badness(width, max_width, stretch, shrink) };
if !b.is_finite() {
continue; }
let penalty = if ends_in_hyphen { params.hyphen_penalty } else { 0.0 };
let base_d = demerits(b, penalty);
for r in 0..run_states {
if !best[i][r].is_finite() {
continue;
}
let new_r = if ends_in_hyphen {
let candidate_r = r + 1;
if candidate_r > limit as usize {
continue; }
candidate_r
} else {
0
};
let mut d = base_d;
if ends_in_hyphen && r > 0 {
d += params.double_hyphen_demerit;
}
let total = best[i][r] + d;
if total < best[j][new_r] {
best[j][new_r] = total;
via[j][new_r] = (i, r);
}
}
}
}
let last = n - 1;
let best_r = (0..run_states)
.filter(|&r| best[last][r].is_finite())
.min_by(|&a, &b| best[last][a].partial_cmp(&best[last][b]).unwrap_or(std::cmp::Ordering::Equal));
let best_r = best_r?;
let mut path = vec![last];
let mut cur = (last, best_r);
while cur.0 != 0 {
cur = via[cur.0][cur.1];
path.push(cur.0);
}
path.reverse();
Some(path)
}
fn reconstruct_lines(atoms: &[Atom], candidates: &[usize], path: &[usize], hyphens: &mut HyphenCache<'_>) -> Vec<Vec<Atom>> {
let mut lines = Vec::with_capacity(path.len().saturating_sub(1));
for w in path.windows(2) {
let c_i = candidates[w[0]];
let c_j = candidates[w[1]];
let slice = &atoms[c_i..c_j];
let ends_in_hyphen = matches!(slice.last(), Some(Atom::Text(t)) if t.hyphen_break);
let mut line: Vec<Atom> = trim_trailing_discardables(slice).to_vec();
if ends_in_hyphen {
let run_index = run_index_of(&slice[slice.len() - 1]);
let (glyph, advance, shape_font) = hyphens.get(run_index);
let (ascent, descent) = match slice.last() {
Some(Atom::Text(t)) => (t.ascent, t.descent),
_ => (0.0, 0.0),
};
line.push(Atom::Text(TextAtom {
run_index,
glyphs: vec![glyph],
width: advance,
ascent,
descent,
shape_font,
is_glue: false,
hyphen_break: false,
}));
}
lines.push(line);
}
lines
}
#[cfg(test)]
fn score_lines(lines: &[Vec<Atom>], max_width: f64, params: LineBreakParams) -> f64 {
let max_width = if max_width.is_finite() { max_width.max(1.0) } else { f64::MAX };
let last_index = lines.len().saturating_sub(1);
let mut total = 0.0_f64;
let mut prev_hyphen = false;
for (i, line) in lines.iter().enumerate() {
let width: f64 = line.iter().map(greedy::atom_width).sum();
let mut stretch = 0.0_f64;
let mut shrink = 0.0_f64;
for atom in line {
if let Atom::Text(t) = atom {
if t.is_glue {
stretch += t.width * params.glue_stretch_ratio;
shrink += t.width * params.glue_shrink_ratio;
}
}
}
let b = if i == last_index { last_line_badness(width, max_width, shrink) } else { badness(width, max_width, stretch, shrink) };
let ends_in_hyphen = matches!(line.last(), Some(Atom::Text(t)) if t.hyphen_break);
let penalty = if ends_in_hyphen { params.hyphen_penalty } else { 0.0 };
let mut d = demerits(b, penalty);
if ends_in_hyphen && prev_hyphen {
d += params.double_hyphen_demerit;
}
total += d;
prev_hyphen = ends_in_hyphen;
}
total
}
#[cfg(test)]
mod tests {
use super::*;
use crate::layout::layout_paragraph;
use crate::linebreak::BreakStrategy;
use crate::model::{FontSpec, ParagraphAlign, StyledRun};
use crate::shape::CosmicShaper;
use uzor::fonts::FontFamily;
fn word_atom(hyphen_break: bool) -> Atom {
Atom::Text(TextAtom {
run_index: 0,
glyphs: vec![AtomGlyph { cluster: "x".to_string(), x: 0.0, y_offset: 0.0, advance: 10.0, width: 10.0 }],
width: 10.0,
ascent: 12.0,
descent: 4.0,
shape_font: FontSpec::default(),
is_glue: false,
hyphen_break,
})
}
fn glue_atom(width: f64) -> Atom {
Atom::Text(TextAtom {
run_index: 0,
glyphs: vec![AtomGlyph { cluster: " ".to_string(), x: 0.0, y_offset: 0.0, advance: width, width }],
width,
ascent: 0.0,
descent: 0.0,
shape_font: FontSpec::default(),
is_glue: true,
hyphen_break: false,
})
}
#[test]
fn double_hyphen_demerit_penalizes_two_consecutive_hyphen_ending_lines() {
let with_double = vec![vec![word_atom(true)], vec![word_atom(true)], vec![word_atom(false)]];
let without_double = vec![vec![word_atom(true)], vec![word_atom(false)], vec![word_atom(false)]];
let params = LineBreakParams::default();
assert!(score_lines(&with_double, 1000.0, params) > score_lines(&without_double, 1000.0, params));
}
#[test]
fn knuth_plass_total_demerits_is_at_most_greedys_for_the_same_atoms() {
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let text = "The quick brown fox jumps over the lazy dog and then keeps \
running further down a very long and winding road without ever \
stopping for a rest, which is exactly the kind of paragraph a \
first-fit greedy packer tends to leave visibly raggeder than it \
needs to.";
let runs = [StyledRun::new(text, font)];
let max_width = 150.0;
let paragraph = Paragraph::new(&runs, max_width);
let shaper = CosmicShaper::headless();
let atoms = greedy::build_atom_stream(¶graph, &shaper);
let greedy_lines = greedy::pack_lines(atoms.clone(), max_width);
let kp_lines = pack_lines(atoms, ¶graph, &shaper);
assert!(greedy_lines.len() > 1 && kp_lines.len() > 1, "fixture must wrap to multiple lines");
let params = LineBreakParams::default();
let greedy_score = score_lines(&greedy_lines, max_width, params);
let kp_score = score_lines(&kp_lines, max_width, params);
assert!(
kp_score <= greedy_score + 1e-6,
"KP ({kp_score}) must be at most as costly as greedy's grouping ({greedy_score})"
);
}
const HYPHEN_DENSE_TEXT: &str = "Understanding internationalization and interoperability requires extraordinary \
counterproductive administrative documentation, particularly regarding \
responsibility, accountability, and extraordinary characterization \
of multidimensional configuration parameters across implementations.";
const HYPHEN_DENSE_WIDTH: f64 = 150.0;
fn max_consecutive_hyphen_lines(layout: &crate::layout::ParagraphLayout) -> usize {
let mut max_consec = 0usize;
let mut cur = 0usize;
for line in &layout.lines {
let ends_hyphen = layout.glyphs.iter().filter(|g| g.line_index == line.line_index).last().is_some_and(|g| g.cluster == "-");
if ends_hyphen {
cur += 1;
max_consec = max_consec.max(cur);
} else {
cur = 0;
}
}
max_consec
}
fn hyphen_dense_paragraph<'a>(runs: &'a [StyledRun<'a>], max_consecutive_hyphens: Option<u8>) -> Paragraph<'a> {
let mut p = Paragraph::new(runs, HYPHEN_DENSE_WIDTH).with_break_strategy(BreakStrategy::KnuthPlass).with_hyphenation(Hyphenation::English);
if let Some(limit) = max_consecutive_hyphens {
p = p.with_max_consecutive_hyphens(limit);
}
p
}
#[test]
fn default_max_consecutive_hyphens_is_byte_identical_to_the_unconstrained_path() {
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let runs = [StyledRun::new(HYPHEN_DENSE_TEXT, font)];
let paragraph = hyphen_dense_paragraph(&runs, None);
assert_eq!(paragraph.max_consecutive_hyphens, None);
let shaper = CosmicShaper::headless();
let params = paragraph.line_break_params;
let atoms_a = greedy::build_atom_stream(¶graph, &shaper);
let atoms_b = greedy::build_atom_stream(¶graph, &shaper);
let expanded_a = hyphenate::expand_hyphenation(atoms_a, paragraph.hyphenation, params.left_min, params.right_min);
let expanded_b = hyphenate::expand_hyphenation(atoms_b, paragraph.hyphenation, params.left_min, params.right_min);
let via_dispatch = pack_lines(expanded_a.clone(), ¶graph, &shaper);
let via_direct = pack_lines_unconstrained(expanded_b, ¶graph, &shaper);
assert_eq!(via_dispatch.len(), via_direct.len());
for (a, b) in via_dispatch.iter().zip(via_direct.iter()) {
assert_eq!(a.len(), b.len());
}
let layout = layout_paragraph(¶graph, &shaper);
assert!(max_consecutive_hyphen_lines(&layout) >= 3, "fixture must be hyphen-dense enough to prove the hard constraint actually engages");
}
#[test]
fn hard_limit_of_one_forbids_any_two_adjacent_hyphen_ending_lines() {
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let runs = [StyledRun::new(HYPHEN_DENSE_TEXT, font)];
let paragraph = hyphen_dense_paragraph(&runs, Some(1));
let shaper = CosmicShaper::headless();
let layout = layout_paragraph(¶graph, &shaper);
assert!(layout.lines.len() > 1, "fixture must still wrap to multiple lines under the constraint");
assert_eq!(max_consecutive_hyphen_lines(&layout), 1, "a limit of 1 must cap the longest consecutive-hyphen run at exactly 1 (never 0 lines simply refusing to hyphenate, and never 2+)");
assert!(layout.glyphs.iter().any(|g| g.cluster == "-"), "a limit of 1 still permits isolated (non-adjacent) hyphen breaks");
}
#[test]
fn hard_limit_of_zero_forbids_every_hyphen_break() {
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let runs = [StyledRun::new(HYPHEN_DENSE_TEXT, font)];
let paragraph = hyphen_dense_paragraph(&runs, Some(0));
let shaper = CosmicShaper::headless();
let layout = layout_paragraph(¶graph, &shaper);
assert!(layout.lines.len() > 1, "fixture must still wrap (via ordinary word breaks) with hyphenation fully suppressed");
assert!(!layout.glyphs.iter().any(|g| g.cluster == "-"), "a limit of 0 must produce ZERO hyphen glyphs anywhere in the layout");
}
#[test]
fn a_tighter_limit_than_the_natural_run_length_strictly_reduces_it() {
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let runs = [StyledRun::new(HYPHEN_DENSE_TEXT, font)];
let shaper = CosmicShaper::headless();
let unconstrained = layout_paragraph(&hyphen_dense_paragraph(&runs, None), &shaper);
let limited = layout_paragraph(&hyphen_dense_paragraph(&runs, Some(2)), &shaper);
let unconstrained_run = max_consecutive_hyphen_lines(&unconstrained);
let limited_run = max_consecutive_hyphen_lines(&limited);
assert!(unconstrained_run > 2, "fixture's own unconstrained run must exceed the limit under test");
assert!(limited_run <= 2, "limited layout must never exceed the hard cap, got {limited_run}");
assert!(limited_run < unconstrained_run, "the constraint must genuinely change the chosen breakpoints, got limited={limited_run} unconstrained={unconstrained_run}");
}
#[test]
fn default_line_break_params_reproduce_the_hardcoded_constants_exactly() {
let p = LineBreakParams::default();
assert_eq!(p.hyphen_penalty, 50.0);
assert_eq!(p.double_hyphen_demerit, 3000.0);
assert_eq!(p.glue_stretch_ratio, 0.5);
assert_eq!(p.glue_shrink_ratio, 1.0 / 3.0);
assert_eq!(p.left_min, 2);
assert_eq!(p.right_min, 3);
}
#[test]
fn non_default_hyphen_penalty_changes_which_breaks_knuth_plass_chooses() {
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let runs = [StyledRun::new(HYPHEN_DENSE_TEXT, font)];
let width = 350.0;
let shaper = CosmicShaper::headless();
let default_paragraph = Paragraph::new(&runs, width).with_break_strategy(BreakStrategy::KnuthPlass).with_hyphenation(Hyphenation::English);
let default_layout = layout_paragraph(&default_paragraph, &shaper);
assert!(default_layout.glyphs.iter().any(|g| g.cluster == "-"), "fixture must hyphenate freely under default params (regression floor)");
let harsh_params = LineBreakParams { hyphen_penalty: 1.0e8, ..LineBreakParams::default() };
let harsh_paragraph = Paragraph::new(&runs, width)
.with_break_strategy(BreakStrategy::KnuthPlass)
.with_hyphenation(Hyphenation::English)
.with_line_break_params(harsh_params);
let harsh_layout = layout_paragraph(&harsh_paragraph, &shaper);
assert!(
!harsh_layout.glyphs.iter().any(|g| g.cluster == "-"),
"a prohibitively high hyphen_penalty must eliminate every hyphen break this fixture otherwise takes freely"
);
assert_ne!(
default_layout.lines.len(),
harsh_layout.lines.len(),
"eliminating every hyphen break must genuinely change the line count — a real layout change, not decoration"
);
}
#[test]
fn non_default_double_hyphen_demerit_reduces_consecutive_hyphen_runs() {
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let runs = [StyledRun::new(HYPHEN_DENSE_TEXT, font)];
let width = 400.0;
let shaper = CosmicShaper::headless();
let default_paragraph =
Paragraph::new(&runs, width).with_break_strategy(BreakStrategy::KnuthPlass).with_hyphenation(Hyphenation::English);
let default_layout = layout_paragraph(&default_paragraph, &shaper);
let default_run = max_consecutive_hyphen_lines(&default_layout);
assert!(default_run >= 2, "fixture must have a real consecutive-hyphen run under default params (regression floor), got {default_run}");
let harsh_params = LineBreakParams { double_hyphen_demerit: 1.0e9, ..LineBreakParams::default() };
let harsh_paragraph = Paragraph::new(&runs, width)
.with_break_strategy(BreakStrategy::KnuthPlass)
.with_hyphenation(Hyphenation::English)
.with_line_break_params(harsh_params);
let harsh_layout = layout_paragraph(&harsh_paragraph, &shaper);
let harsh_run = max_consecutive_hyphen_lines(&harsh_layout);
assert!(harsh_run < default_run, "a huge double_hyphen_demerit must strictly shrink the longest consecutive-hyphen run, got default={default_run} harsh={harsh_run}");
assert_eq!(default_layout.lines.len(), harsh_layout.lines.len(), "regression floor: the two layouts should differ in WHICH lines hyphenate, not in overall line count, at this width");
}
#[test]
fn non_default_glue_ratios_change_span_metrics_stretch_and_shrink() {
let atoms = vec![word_atom(false), glue_atom(20.0), word_atom(false)];
let default_params = LineBreakParams::default();
let (_, default_stretch, default_shrink) = span_metrics(&atoms, 0.0, false, default_params);
let tight_params = LineBreakParams { glue_stretch_ratio: 0.1, glue_shrink_ratio: 0.05, ..LineBreakParams::default() };
let (_, tight_stretch, tight_shrink) = span_metrics(&atoms, 0.0, false, tight_params);
assert!(tight_stretch < default_stretch, "a smaller glue_stretch_ratio must shrink the returned stretch pool, default={default_stretch} tight={tight_stretch}");
assert!(tight_shrink < default_shrink, "a smaller glue_shrink_ratio must shrink the returned shrink pool, default={default_shrink} tight={tight_shrink}");
}
#[test]
fn non_default_left_right_min_changes_the_laid_out_hyphenation() {
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let runs = [StyledRun::new(HYPHEN_DENSE_TEXT, font)];
let shaper = CosmicShaper::headless();
let default_paragraph =
Paragraph::new(&runs, HYPHEN_DENSE_WIDTH).with_break_strategy(BreakStrategy::KnuthPlass).with_hyphenation(Hyphenation::English);
let default_layout = layout_paragraph(&default_paragraph, &shaper);
let default_hyphens = default_layout.glyphs.iter().filter(|g| g.cluster == "-").count();
assert!(default_hyphens > 0, "fixture must hyphenate under default params (regression floor)");
let wide_params = LineBreakParams { left_min: 6, right_min: 6, ..LineBreakParams::default() };
let wide_paragraph = Paragraph::new(&runs, HYPHEN_DENSE_WIDTH)
.with_break_strategy(BreakStrategy::KnuthPlass)
.with_hyphenation(Hyphenation::English)
.with_line_break_params(wide_params);
let wide_layout = layout_paragraph(&wide_paragraph, &shaper);
let wide_hyphens = wide_layout.glyphs.iter().filter(|g| g.cluster == "-").count();
assert!(wide_hyphens < default_hyphens, "a much wider left_min/right_min must strictly reduce the laid-out hyphen count, default={default_hyphens} wide={wide_hyphens}");
}
struct CountingRecorder {
key: &'static str,
total: std::sync::Arc<std::sync::atomic::AtomicU64>,
}
struct AtomicCounter(std::sync::Arc<std::sync::atomic::AtomicU64>);
impl metrics::CounterFn for AtomicCounter {
fn increment(&self, value: u64) {
self.0.fetch_add(value, std::sync::atomic::Ordering::SeqCst);
}
fn absolute(&self, value: u64) {
self.0.store(value, std::sync::atomic::Ordering::SeqCst);
}
}
impl metrics::Recorder for CountingRecorder {
fn describe_counter(&self, _key: metrics::KeyName, _unit: Option<metrics::Unit>, _description: metrics::SharedString) {}
fn describe_gauge(&self, _key: metrics::KeyName, _unit: Option<metrics::Unit>, _description: metrics::SharedString) {}
fn describe_histogram(&self, _key: metrics::KeyName, _unit: Option<metrics::Unit>, _description: metrics::SharedString) {}
fn register_counter(&self, key: &metrics::Key, _metadata: &metrics::Metadata<'_>) -> metrics::Counter {
if key.name() == self.key {
metrics::Counter::from_arc(std::sync::Arc::new(AtomicCounter(self.total.clone())))
} else {
metrics::Counter::noop()
}
}
fn register_gauge(&self, _key: &metrics::Key, _metadata: &metrics::Metadata<'_>) -> metrics::Gauge {
metrics::Gauge::noop()
}
fn register_histogram(&self, _key: &metrics::Key, _metadata: &metrics::Metadata<'_>) -> metrics::Histogram {
metrics::Histogram::noop()
}
}
#[test]
fn no_feasible_breaking_falls_back_and_signals_via_metrics_and_diagnostics() {
let font = FontSpec::new(FontFamily::Roboto, 16.0);
const TEXT: &str = "Supercalifragilisticexpialidocioussesquipedalianism";
let runs = [StyledRun::new(TEXT, font)];
let max_width = 50.0;
let paragraph = Paragraph::new(&runs, max_width).with_align(ParagraphAlign::Justify).with_break_strategy(BreakStrategy::KnuthPlass);
let shaper = CosmicShaper::headless();
let total = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
let recorder = CountingRecorder { key: crate::metrics_keys::KEY_LINEBREAK_OVERFULL_FALLBACK, total: total.clone() };
let (layout, diag) =
metrics::with_local_recorder(&recorder, || crate::layout::layout_paragraph_diagnosed(¶graph, &shaper));
assert_eq!(layout.lines.len(), 1, "one genuinely unbreakable word must still render as exactly one line, never panic or vanish");
assert!(diag.overfull_fallback_used, "no feasible breaking exists for this fixture — the deliberate overfull-hbox fallback must have fired");
assert_eq!(diag.overfull_line_count, 1);
assert!(total.load(std::sync::atomic::Ordering::SeqCst) >= 1, "the fallback must increment the metrics counter at least once — got {}", total.load(std::sync::atomic::Ordering::SeqCst));
let last_glyph = layout.glyphs.last().expect("the single unbreakable word must still produce real glyphs");
assert!(last_glyph.x + last_glyph.advance > max_width, "the fallback line must genuinely render past the measure (that's the whole point of it being flagged) — never silently clipped to fit");
}
#[test]
fn justified_lines_never_exceed_the_measure_unless_the_fallback_fired() {
const TEXTS: [&str; 4] = [
"Good typography is invisible, or nearly so: a well-set paragraph reads evenly, without ragged holes or crowded lines.",
"The quick brown fox jumps over the lazy dog and then keeps running further down the road without ever stopping for a rest.",
HYPHEN_DENSE_TEXT,
"Показательный документ подтверждает поддержку кириллического текста, а узкая колонка оправданного текста быстро показывает неравномерные промежутки между словами.",
];
const WIDTHS: [f64; 9] = [60.0, 90.0, 120.0, 150.0, 180.0, 220.0, 260.0, 320.0, 400.0];
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let shaper = CosmicShaper::headless();
let mut checked = 0usize;
for text in TEXTS {
let runs = [StyledRun::new(text, font)];
for &max_width in &WIDTHS {
for hyphenation in [Hyphenation::None, Hyphenation::English] {
let paragraph = Paragraph::new(&runs, max_width)
.with_align(ParagraphAlign::Justify)
.with_break_strategy(BreakStrategy::KnuthPlass)
.with_hyphenation(hyphenation);
let (layout, diag) = crate::layout::layout_paragraph_diagnosed(¶graph, &shaper);
let last_index = layout.lines.len().saturating_sub(1);
for line in &layout.lines {
if line.line_index == last_index {
continue; }
let Some(last_glyph) = layout.glyphs.iter().filter(|g| g.line_index == line.line_index).last() else { continue };
let advance_end = last_glyph.x + last_glyph.advance;
checked += 1;
assert!(
advance_end <= max_width + 1e-6 || diag.overfull_fallback_used,
"line {} of {text:?} at max_width={max_width} hyphenation={hyphenation:?} overshoots ({advance_end} > {max_width}) but overfull_fallback_used is false — the fix's own invariant is broken",
line.line_index
);
}
}
}
}
assert!(checked > 50, "this sweep must exercise a real number of justified lines, got {checked}");
}
}