use crate::linebreak::BreakStrategy;
use crate::model::{InlineBox, Paragraph, ParagraphAlign, ProtrusionTable, StyledRun};
use crate::shape::LineShaper;
use super::baseline::resolve_line_metrics;
use super::glyph_layout::{DecorationKind, DecorationSpan, GlyphLayout, LineBox, ParagraphLayout, PlacedInlineBox};
use super::greedy::{self, Atom};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct LineBreakDiagnostics {
pub overfull_fallback_used: bool,
pub overfull_line_count: usize,
}
const OVERFULL_EPSILON: f64 = 1e-6;
pub fn layout_paragraph(paragraph: &Paragraph<'_>, shaper: &dyn LineShaper) -> ParagraphLayout {
layout_paragraph_diagnosed(paragraph, shaper).0
}
pub fn layout_paragraph_diagnosed(paragraph: &Paragraph<'_>, shaper: &dyn LineShaper) -> (ParagraphLayout, LineBreakDiagnostics) {
let atoms = greedy::build_atom_stream(paragraph, shaper);
let packed = match paragraph.break_strategy {
BreakStrategy::Greedy => greedy::pack_lines(atoms, paragraph.max_width),
BreakStrategy::KnuthPlass => crate::linebreak::knuth_plass::pack_lines(atoms, paragraph, shaper),
};
if packed.is_empty() {
return (ParagraphLayout::default(), LineBreakDiagnostics::default());
}
let mut glyphs = Vec::new();
let mut lines = Vec::with_capacity(packed.len());
let mut boxes = Vec::new();
let mut y_top = 0.0_f64;
let last_index = packed.len() - 1;
let mut line_flush_start: Vec<bool> = Vec::with_capacity(packed.len());
let mut line_flush_end: Vec<bool> = Vec::with_capacity(packed.len());
let mut overfull_line_count = 0usize;
for (line_index, line_atoms) in packed.into_iter().enumerate() {
let natural_width: f64 = line_atoms.iter().map(greedy::atom_width).sum();
let line_metrics =
resolve_line_metrics(line_atoms.iter().map(greedy::atom_metrics), paragraph.line_height);
let glue_count = line_atoms.iter().filter(|a| matches!(a, Atom::Text(t) if t.is_glue)).count();
let can_justify = paragraph.align == ParagraphAlign::Justify
&& line_index != last_index
&& glue_count > 0
&& paragraph.max_width.is_finite();
let end_flush = match paragraph.align {
ParagraphAlign::Right => paragraph.max_width.is_finite(),
ParagraphAlign::Justify => can_justify,
ParagraphAlign::Left | ParagraphAlign::Center => false,
};
let start_flush = matches!(paragraph.align, ParagraphAlign::Left | ParagraphAlign::Justify);
line_flush_start.push(start_flush);
line_flush_end.push(end_flush);
let needs_shrink =
!can_justify && line_index != last_index && glue_count > 0 && paragraph.max_width.is_finite() && natural_width > paragraph.max_width;
let glue_extras = if can_justify || needs_shrink {
glue_extras_for_line(
&line_atoms,
glue_count,
(paragraph.max_width - natural_width) / glue_count as f64,
paragraph.line_break_params.glue_shrink_ratio,
)
} else {
vec![0.0; glue_count]
};
let content_width = natural_width + glue_extras.iter().sum::<f64>();
let align_shift = match paragraph.align {
ParagraphAlign::Left | ParagraphAlign::Justify => 0.0,
ParagraphAlign::Center => {
if paragraph.max_width.is_finite() { (paragraph.max_width - content_width) / 2.0 } else { 0.0 }
}
ParagraphAlign::Right => {
if paragraph.max_width.is_finite() { paragraph.max_width - content_width } else { 0.0 }
}
};
let baseline_y = y_top + line_metrics.ascent;
let mut pen_x = align_shift;
let mut glue_ordinal = 0usize;
for atom in &line_atoms {
match atom {
Atom::Text(t) => {
let run = ¶graph.runs[t.run_index];
let vshift = run.vertical_align.baseline_shift(run.font.size_px);
for g in &t.glyphs {
glyphs.push(GlyphLayout {
cluster: g.cluster.clone(),
run_index: t.run_index,
line_index,
x: pen_x + g.x,
y: baseline_y + g.y_offset + vshift,
advance: g.advance,
width: g.width,
font: t.shape_font,
color: run.color,
});
}
pen_x += t.width;
if t.is_glue {
pen_x += glue_extras[glue_ordinal];
glue_ordinal += 1;
}
}
Atom::Box(inline_box) => {
boxes.push(placed_box(*inline_box, line_index, pen_x, baseline_y));
pen_x += inline_box.width();
}
Atom::Break => {}
}
}
if paragraph.max_width.is_finite() && content_width > paragraph.max_width + OVERFULL_EPSILON {
overfull_line_count += 1;
}
lines.push(LineBox { line_index, y_top, baseline_y, height: line_metrics.height, content_width });
y_top += line_metrics.height;
}
if let Some(table) = paragraph.protrusion {
apply_protrusion(&mut glyphs, &lines, &line_flush_start, &line_flush_end, table);
}
let width = lines.iter().map(|l| l.content_width).fold(0.0_f64, f64::max);
let height = lines.last().map(|l| l.y_top + l.height).unwrap_or(0.0);
let decorations = build_decoration_spans(&glyphs, paragraph.runs);
let layout = ParagraphLayout { glyphs, lines, boxes, decorations, width, height };
let diagnostics = LineBreakDiagnostics { overfull_fallback_used: overfull_line_count > 0, overfull_line_count };
(layout, diagnostics)
}
fn apply_protrusion(glyphs: &mut [GlyphLayout], lines: &[LineBox], flush_start: &[bool], flush_end: &[bool], table: &ProtrusionTable) {
let mut first_idx: Vec<Option<usize>> = vec![None; lines.len()];
let mut last_idx: Vec<Option<usize>> = vec![None; lines.len()];
for (i, g) in glyphs.iter().enumerate() {
if g.cluster.is_empty() {
continue; }
if let Some(slot) = first_idx.get_mut(g.line_index) {
slot.get_or_insert(i);
}
if let Some(slot) = last_idx.get_mut(g.line_index) {
*slot = Some(i);
}
}
for line_index in 0..lines.len() {
if flush_start.get(line_index).copied().unwrap_or(false) {
if let Some(i) = first_idx[line_index] {
protrude_start(&mut glyphs[i], table);
}
}
if flush_end.get(line_index).copied().unwrap_or(false) {
if let Some(i) = last_idx[line_index] {
protrude_end(&mut glyphs[i], table);
}
}
}
}
fn protrude_start(glyph: &mut GlyphLayout, table: &ProtrusionTable) {
if let Some(ch) = single_char(&glyph.cluster) {
let factors = table.get(ch);
if factors.start > 0.0 {
glyph.x -= factors.start * glyph.advance;
}
}
}
fn protrude_end(glyph: &mut GlyphLayout, table: &ProtrusionTable) {
if let Some(ch) = single_char(&glyph.cluster) {
let factors = table.get(ch);
if factors.end > 0.0 {
glyph.x += factors.end * glyph.advance;
}
}
}
fn single_char(s: &str) -> Option<char> {
let mut chars = s.chars();
let c = chars.next()?;
if chars.next().is_none() {
Some(c)
} else {
None
}
}
fn is_single_glyph_word(atom: &Atom) -> bool {
matches!(atom, Atom::Text(t) if !t.is_glue && t.glyphs.len() == 1)
}
fn protected_glue_mask(line_atoms: &[Atom]) -> Vec<bool> {
let mut mask = Vec::new();
for (i, atom) in line_atoms.iter().enumerate() {
if !matches!(atom, Atom::Text(t) if t.is_glue) {
continue;
}
let before = i > 0 && is_single_glyph_word(&line_atoms[i - 1]);
let after = i + 1 < line_atoms.len() && is_single_glyph_word(&line_atoms[i + 1]);
mask.push(before || after);
}
mask
}
fn glue_extras_for_line(line_atoms: &[Atom], glue_count: usize, raw: f64, shrink_ratio: f64) -> Vec<f64> {
if raw >= 0.0 {
return vec![raw; glue_count];
}
let protected = protected_glue_mask(line_atoms);
debug_assert_eq!(protected.len(), glue_count);
let glue_widths: Vec<f64> = line_atoms
.iter()
.filter_map(|a| if let Atom::Text(t) = a { t.is_glue.then_some(t.width) } else { None })
.collect();
let regular_count = protected.iter().filter(|&&p| !p).count();
let deficit = raw * glue_count as f64; if regular_count == 0 {
let floor = -(glue_widths.iter().copied().fold(f64::MAX, f64::min) * shrink_ratio);
return vec![raw.max(floor); glue_count];
}
let min_regular_width =
glue_widths.iter().zip(&protected).filter_map(|(&w, &p)| (!p).then_some(w)).fold(f64::MAX, f64::min);
let floor = -(min_regular_width * shrink_ratio);
let per_regular = (deficit / regular_count as f64).max(floor);
protected.iter().map(|&p| if p { 0.0 } else { per_regular }).collect()
}
const UNDERLINE_OFFSET_EM: f64 = 0.08;
const STRIKETHROUGH_OFFSET_EM: f64 = 0.30;
const DECORATION_THICKNESS_EM: f64 = 0.06;
const MIN_DECORATION_THICKNESS_PX: f64 = 1.0;
fn build_decoration_spans(glyphs: &[GlyphLayout], runs: &[StyledRun<'_>]) -> Vec<DecorationSpan> {
const GAP_EPSILON: f64 = 0.01;
let mut spans = Vec::new();
let mut current: Option<(usize, usize, f64, f64, f64)> = None;
for g in glyphs {
if g.cluster.is_empty() {
continue; }
let Some(run) = runs.get(g.run_index) else { continue };
if run.decoration.is_none() {
flush_decoration_span(current.take(), runs, &mut spans);
continue;
}
let continues = current
.is_some_and(|(line, run_idx, _, end_x, _)| line == g.line_index && run_idx == g.run_index && (g.x - end_x).abs() < GAP_EPSILON);
if continues {
if let Some(span) = &mut current {
span.3 = g.x + g.advance;
}
} else {
flush_decoration_span(current.take(), runs, &mut spans);
current = Some((g.line_index, g.run_index, g.x, g.x + g.advance, g.y));
}
}
flush_decoration_span(current.take(), runs, &mut spans);
spans
}
fn flush_decoration_span(current: Option<(usize, usize, f64, f64, f64)>, runs: &[StyledRun<'_>], spans: &mut Vec<DecorationSpan>) {
let Some((line_index, run_index, x_start, x_end, baseline_y)) = current else { return };
let Some(run) = runs.get(run_index) else { return };
let size = run.font.size_px;
let thickness = (size * DECORATION_THICKNESS_EM).max(MIN_DECORATION_THICKNESS_PX);
if run.decoration.underline {
spans.push(DecorationSpan {
run_index,
line_index,
kind: DecorationKind::Underline,
x_start,
x_end,
y: baseline_y + size * UNDERLINE_OFFSET_EM,
thickness,
color: run.color,
});
}
if run.decoration.strikethrough {
spans.push(DecorationSpan {
run_index,
line_index,
kind: DecorationKind::Strikethrough,
x_start,
x_end,
y: baseline_y - size * STRIKETHROUGH_OFFSET_EM,
thickness,
color: run.color,
});
}
}
fn placed_box(inline_box: InlineBox, line_index: usize, x: f64, baseline_y: f64) -> PlacedInlineBox {
PlacedInlineBox {
id: inline_box.id,
line_index,
x,
y_top: baseline_y - inline_box.height(),
width: inline_box.width(),
height: inline_box.height(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::layout::GlyphLayout;
use crate::linebreak::Hyphenation;
use crate::model::{FontSpec, InlineBox, InlineBoxSlot, StyledRun};
use crate::shape::CosmicShaper;
use uzor::fonts::FontFamily;
#[test]
fn mixed_size_runs_share_one_baseline_and_line_height_is_max_run_height() {
let small = FontSpec::new(FontFamily::Roboto, 14.0);
let big = FontSpec::new(FontFamily::Roboto, 28.0);
let runs = [StyledRun::new("small ", small), StyledRun::new("BIG", big)];
let paragraph = Paragraph::new(&runs, 1000.0);
let shaper = CosmicShaper::headless();
let layout = layout_paragraph(¶graph, &shaper);
assert_eq!(layout.lines.len(), 1, "fixture must fit on one line");
let ys: Vec<f64> = layout.glyphs.iter().map(|g| g.y).collect();
assert!(!ys.is_empty());
for y in &ys {
assert!((y - ys[0]).abs() < 1e-6, "mixed-size runs must share one baseline, got {ys:?}");
}
let small_only = crate::layout::layout_text("small only", &small, 1000.0, &shaper);
assert!(
layout.lines[0].height > small_only.lines[0].height,
"line height must reflect the taller run"
);
}
#[test]
fn justify_stretches_every_line_except_the_last() {
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let text = "one two three four five six seven eight nine ten eleven twelve";
let runs = [StyledRun::new(text, font)];
let max_width = 220.0;
let paragraph = Paragraph::new(&runs, max_width).with_align(ParagraphAlign::Justify);
let shaper = CosmicShaper::headless();
let layout = layout_paragraph(¶graph, &shaper);
assert!(layout.lines.len() > 1, "fixture must wrap to multiple lines");
let last_index = layout.lines.len() - 1;
for line in &layout.lines {
let glyphs_on_line: Vec<&GlyphLayout> =
layout.glyphs.iter().filter(|g| g.line_index == line.line_index).collect();
let Some(last_glyph) = glyphs_on_line.last() else { continue };
let rendered_extent = last_glyph.x + last_glyph.advance;
if line.line_index == last_index {
assert!(rendered_extent < max_width - 1.0, "last line must NOT be stretched, got {rendered_extent}");
} else {
assert!(
(rendered_extent - max_width).abs() < 1.0,
"line {} should reach max_width {max_width}, got {rendered_extent}",
line.line_index
);
assert!((line.content_width - max_width).abs() < 1.0);
}
}
}
#[test]
fn justify_single_word_line_does_not_divide_by_zero() {
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let runs = [StyledRun::new("Solo", font)];
let paragraph = Paragraph::new(&runs, 300.0).with_align(ParagraphAlign::Justify);
let shaper = CosmicShaper::headless();
let layout = layout_paragraph(¶graph, &shaper);
assert_eq!(layout.lines.len(), 1);
assert!(layout.lines[0].content_width.is_finite());
assert!(layout.lines[0].content_width < 300.0, "a single word must not be force-stretched");
}
#[test]
fn inline_box_reserves_width_and_places_at_correct_x_and_baseline() {
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let runs = [StyledRun::new("Look ", font), StyledRun::new(" after the icon.", font)];
let box_width = 40.0;
let box_height = 20.0;
let inline_box = InlineBox::in_flow(1, box_width, box_height);
let slots = [InlineBoxSlot::new(0, "Look ".len(), inline_box)];
let paragraph = Paragraph::new(&runs, 1000.0).with_inline_boxes(&slots);
let shaper = CosmicShaper::headless();
let layout = layout_paragraph(¶graph, &shaper);
assert_eq!(layout.boxes.len(), 1);
let placed = layout.boxes[0];
assert_eq!(placed.id, 1);
assert_eq!(placed.width, box_width);
assert_eq!(placed.height, box_height);
for glyph in &layout.glyphs {
if glyph.run_index == 0 {
assert!(glyph.x + glyph.advance <= placed.x + 0.5, "text before the box must not overlap it");
} else {
assert!(
glyph.x + 0.5 >= placed.x + placed.width,
"text after the box must continue past its reserved width"
);
}
}
let line = &layout.lines[placed.line_index];
assert!(
(placed.y_top + placed.height - line.baseline_y).abs() < 1e-6,
"box's bottom edge must sit on the line baseline"
);
}
#[test]
fn inline_box_taller_than_text_grows_the_line() {
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let runs = [StyledRun::new("short text", font)];
let tall_box = InlineBox::in_flow(7, 10.0, 200.0);
let slots = [InlineBoxSlot::new(0, "short ".len(), tall_box)];
let paragraph = Paragraph::new(&runs, 1000.0).with_inline_boxes(&slots);
let shaper = CosmicShaper::headless();
let with_box = layout_paragraph(¶graph, &shaper);
let without_box = crate::layout::layout_text("short text", &font, 1000.0, &shaper);
assert!(with_box.lines[0].height > without_box.lines[0].height);
}
#[test]
fn default_paragraph_is_byte_identical_to_an_explicit_greedy_strategy() {
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let text = "one two three four five six seven eight nine ten eleven twelve";
let runs = [StyledRun::new(text, font)];
let shaper = CosmicShaper::headless();
let default_paragraph = Paragraph::new(&runs, 220.0);
let explicit_greedy = Paragraph::new(&runs, 220.0).with_break_strategy(BreakStrategy::Greedy);
let a = layout_paragraph(&default_paragraph, &shaper);
let b = layout_paragraph(&explicit_greedy, &shaper);
assert_eq!(a, b);
}
#[test]
fn knuth_plass_lines_stay_within_max_width_with_hyphen_widths_counted() {
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let text = "An understanding of wonderful hyphenation helps a beautiful \
narrow column of business text stay even instead of ragged.";
let runs = [StyledRun::new(text, font)];
let max_width = 260.0;
let paragraph = Paragraph::new(&runs, max_width)
.with_break_strategy(BreakStrategy::KnuthPlass)
.with_hyphenation(Hyphenation::English);
let shaper = CosmicShaper::headless();
let layout = layout_paragraph(¶graph, &shaper);
assert!(layout.lines.len() > 1, "fixture must wrap to multiple lines");
for line in &layout.lines {
assert!(
line.content_width <= max_width + 1.0,
"line {} width {} exceeds max_width {max_width}",
line.line_index,
line.content_width
);
}
assert!(
layout.glyphs.iter().any(|g| g.cluster == "-"),
"this fixture at this width must hit at least one hyphenation break"
);
}
#[test]
fn hyphenation_only_draws_a_hyphen_glyph_when_a_break_actually_lands_there() {
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let text = "An understanding of wonderful hyphenation.";
let runs = [StyledRun::new(text, font)];
let shaper = CosmicShaper::headless();
let narrow = Paragraph::new(&runs, 70.0)
.with_break_strategy(BreakStrategy::KnuthPlass)
.with_hyphenation(Hyphenation::English);
let narrow_layout = layout_paragraph(&narrow, &shaper);
assert!(narrow_layout.lines.len() > 1, "fixture must wrap at this width");
assert!(narrow_layout.glyphs.iter().any(|g| g.cluster == "-"), "narrow column should force a visible hyphen");
let wide = Paragraph::new(&runs, 1000.0)
.with_break_strategy(BreakStrategy::KnuthPlass)
.with_hyphenation(Hyphenation::English);
let wide_layout = layout_paragraph(&wide, &shaper);
assert_eq!(wide_layout.lines.len(), 1, "fixture must fit unwrapped at this width");
assert!(!wide_layout.glyphs.iter().any(|g| g.cluster == "-"), "no break landed, so no hyphen should be drawn");
}
#[test]
fn knuth_plass_hyphenates_and_justifies_a_russian_paragraph() {
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let text = "показательный документ подтверждает поддержку кириллического текста";
let runs = [StyledRun::new(text, font)];
let max_width = 130.0;
let paragraph = Paragraph::new(&runs, max_width)
.with_align(ParagraphAlign::Justify)
.with_break_strategy(BreakStrategy::KnuthPlass)
.with_hyphenation(Hyphenation::Russian);
let shaper = CosmicShaper::headless();
let layout = layout_paragraph(¶graph, &shaper);
assert!(layout.lines.len() > 1, "fixture must wrap to multiple lines at this narrow width");
assert!(layout.glyphs.iter().any(|g| g.cluster == "-"), "a narrow Cyrillic column must hit at least one hyphenation break");
assert!(layout.glyphs.iter().all(|g| g.x.is_finite() && g.y.is_finite()), "every glyph must land at a finite position");
}
#[test]
fn single_letter_word_glue_is_never_shrunk_even_on_a_tight_justified_line() {
let font = FontSpec::new(FontFamily::Roboto, 16.0);
const TEXT: &str = "Walking to my store downtown continues further along a road, then home.";
let runs = [StyledRun::new(TEXT, font)];
let max_width = 250.0; let paragraph = Paragraph::new(&runs, max_width).with_align(ParagraphAlign::Justify).with_break_strategy(BreakStrategy::KnuthPlass);
let shaper = CosmicShaper::headless();
let (layout, diag) = crate::layout::layout_paragraph_diagnosed(¶graph, &shaper);
assert!(!diag.overfull_fallback_used, "this fixture must stay in the primary, feasibility-gated pass — a genuinely feasible, within-capacity shrink, not the deliberate overfull-hbox fallback");
let target_line = layout
.lines
.iter()
.find(|line| {
let glyphs: Vec<&GlyphLayout> = layout.glyphs.iter().filter(|g| g.line_index == line.line_index).collect();
glyphs.windows(3).any(|w| w[0].cluster == " " && w[1].cluster == "a" && w[2].cluster == " ")
})
.expect("fixture must wrap the standalone \"a\" onto some line");
let mut glyphs: Vec<&GlyphLayout> = layout.glyphs.iter().filter(|g| g.line_index == target_line.line_index).collect();
glyphs.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap());
let a_pos = glyphs.windows(3).position(|w| w[0].cluster == " " && w[1].cluster == "a" && w[2].cluster == " ").unwrap();
let glue_before = glyphs[a_pos];
let a_glyph = glyphs[a_pos + 1];
let glue_after = glyphs[a_pos + 2];
let next_glyph = glyphs[a_pos + 3];
let gap_before = a_glyph.x - glue_before.x;
let gap_after = next_glyph.x - glue_after.x;
assert!(
(gap_before - glue_before.advance).abs() < 1e-6,
"glue before the one-letter word must render unshrunk: gap={gap_before} advance={}",
glue_before.advance
);
assert!(
(gap_after - glue_after.advance).abs() < 1e-6,
"glue after the one-letter word must render unshrunk: gap={gap_after} advance={}",
glue_after.advance
);
assert!(gap_after > 0.0, "the rendered gap after a one-letter word must be a real, positive advance");
let mut glue_pairs: Vec<(f64, f64)> = Vec::new(); for w in glyphs.windows(2) {
if w[0].cluster == " " {
glue_pairs.push((w[1].x - w[0].x, w[0].advance));
}
}
assert!(
glue_pairs.iter().any(|&(gap, natural)| gap + 1e-6 < natural),
"line must still contain at least one genuinely shrunk glue elsewhere, proving the fix redistributed the deficit rather than dropping it: {glue_pairs:?}"
);
}
#[test]
fn justify_still_reaches_max_width_under_knuth_plass() {
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let text = "one two three four five six seven eight nine ten eleven twelve";
let runs = [StyledRun::new(text, font)];
let max_width = 220.0;
let paragraph = Paragraph::new(&runs, max_width)
.with_align(ParagraphAlign::Justify)
.with_break_strategy(BreakStrategy::KnuthPlass);
let shaper = CosmicShaper::headless();
let layout = layout_paragraph(¶graph, &shaper);
assert!(layout.lines.len() > 1, "fixture must wrap to multiple lines");
let last_index = layout.lines.len() - 1;
for line in &layout.lines {
if line.line_index == last_index {
continue;
}
assert!(
(line.content_width - max_width).abs() < 1.0,
"line {} should reach max_width {max_width} under Justify+KnuthPlass, got {}",
line.line_index,
line.content_width
);
}
}
#[test]
fn underlined_run_produces_a_decoration_span_at_the_expected_baseline_offset() {
use crate::model::TextDecoration;
let font = FontSpec::new(FontFamily::Roboto, 20.0);
let plain = StyledRun::new("plain ", font);
let underlined = StyledRun::new("underlined", font).with_decoration(TextDecoration::underline());
let runs = [plain, underlined];
let paragraph = Paragraph::new(&runs, 1000.0);
let shaper = CosmicShaper::headless();
let layout = layout_paragraph(¶graph, &shaper);
assert_eq!(layout.lines.len(), 1);
assert_eq!(layout.decorations.len(), 1, "only the decorated run may produce a span");
let span = &layout.decorations[0];
assert_eq!(span.run_index, 1);
assert_eq!(span.kind, crate::layout::DecorationKind::Underline);
assert!(span.thickness > 0.0);
let baseline_y = layout.lines[0].baseline_y;
let expected_y = baseline_y + font.size_px * UNDERLINE_OFFSET_EM;
assert!((span.y - expected_y).abs() < 1e-6, "underline y {} must equal baseline + fallback offset {expected_y}", span.y);
let underlined_glyphs: Vec<&GlyphLayout> = layout.glyphs.iter().filter(|g| g.run_index == 1).collect();
let expected_start = underlined_glyphs.first().unwrap().x;
let last = underlined_glyphs.last().unwrap();
let expected_end = last.x + last.advance;
assert!((span.x_start - expected_start).abs() < 1e-6);
assert!((span.x_end - expected_end).abs() < 1e-6);
}
#[test]
fn strikethrough_span_sits_above_the_baseline() {
use crate::model::TextDecoration;
let font = FontSpec::new(FontFamily::Roboto, 20.0);
let runs = [StyledRun::new("struck", font).with_decoration(TextDecoration::strikethrough())];
let paragraph = Paragraph::new(&runs, 1000.0);
let shaper = CosmicShaper::headless();
let layout = layout_paragraph(¶graph, &shaper);
assert_eq!(layout.decorations.len(), 1);
let span = &layout.decorations[0];
assert_eq!(span.kind, crate::layout::DecorationKind::Strikethrough);
assert!(span.y < layout.lines[0].baseline_y, "strikethrough must sit above the baseline");
}
#[test]
fn undecorated_paragraph_produces_no_decoration_spans() {
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let runs = [StyledRun::new("plain text, nothing decorated", font)];
let paragraph = Paragraph::new(&runs, 1000.0);
let shaper = CosmicShaper::headless();
let layout = layout_paragraph(¶graph, &shaper);
assert!(layout.decorations.is_empty());
}
#[test]
fn decoration_span_breaks_across_a_spliced_inline_box() {
use crate::model::{InlineBox, InlineBoxSlot, TextDecoration};
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let text = "before after";
let split_at = "before".len();
let runs = [StyledRun::new(text, font).with_decoration(TextDecoration::underline())];
let icon = InlineBox::in_flow(9, 30.0, 10.0);
let slots = [InlineBoxSlot::new(0, split_at, icon)];
let paragraph = Paragraph::new(&runs, 1000.0).with_inline_boxes(&slots);
let shaper = CosmicShaper::headless();
let layout = layout_paragraph(¶graph, &shaper);
assert_eq!(layout.decorations.len(), 2, "the box must split the underline into two spans, never one span crossing its gap");
assert!(layout.decorations[0].x_end <= layout.boxes[0].x + 1e-6, "the first span must end at/before the box");
assert!(layout.decorations[1].x_start >= layout.boxes[0].x + layout.boxes[0].width - 1e-6, "the second span must start at/after the box");
}
#[test]
fn protrusion_on_a_justified_line_hangs_the_trailing_period_past_the_measure_by_the_expected_fraction() {
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let text = "One two three four five.\nA second short line follows this one.";
let runs = [StyledRun::new(text, font)];
let max_width = 260.0;
let shaper = CosmicShaper::headless();
let without = Paragraph::new(&runs, max_width).with_align(ParagraphAlign::Justify);
let without_layout = layout_paragraph(&without, &shaper);
assert!(without_layout.lines.len() >= 2, "fixture must produce at least two lines via the forced break");
let without_last =
without_layout.glyphs.iter().filter(|g| g.line_index == 0).last().expect("first line must have glyphs");
assert_eq!(without_last.cluster, ".", "fixture's first line must end in a period");
let measure_edge = without_last.x + without_last.advance;
assert!((measure_edge - max_width).abs() < 1.0, "justify must stretch the non-last first line to reach max_width, got {measure_edge}");
let table = ProtrusionTable::default_punctuation();
let with = Paragraph::new(&runs, max_width).with_align(ParagraphAlign::Justify).with_protrusion(&table);
let with_layout = layout_paragraph(&with, &shaper);
let with_last = with_layout.glyphs.iter().filter(|g| g.line_index == 0).last().expect("first line must have glyphs");
assert_eq!(with_last.cluster, ".");
let rendered_edge = with_last.x + with_last.advance;
assert!(rendered_edge > max_width, "the period must hang PAST the measure, got {rendered_edge} vs max_width {max_width}");
let overhang = rendered_edge - measure_edge;
assert!(
(overhang - with_last.advance).abs() < 1e-6,
"period (ProtrusionFactors::end_only(1.0)) must hang past the measure by EXACTLY its own full advance, got overhang={overhang} advance={}",
with_last.advance
);
}
#[test]
fn protrusion_none_and_an_explicit_empty_table_produce_byte_identical_output() {
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let text = "A justified paragraph with a trailing period, and a comma, too.";
let runs = [StyledRun::new(text, font)];
let max_width = 220.0;
let shaper = CosmicShaper::headless();
let none_paragraph = Paragraph::new(&runs, max_width).with_align(ParagraphAlign::Justify);
assert_eq!(none_paragraph.protrusion, None, "T4 regression floor: default is no protrusion");
let none_layout = layout_paragraph(&none_paragraph, &shaper);
let empty_table = ProtrusionTable::new();
let empty_paragraph = Paragraph::new(&runs, max_width).with_align(ParagraphAlign::Justify).with_protrusion(&empty_table);
let empty_layout = layout_paragraph(&empty_paragraph, &shaper);
assert_eq!(none_layout, empty_layout, "an empty (all-zero) protrusion table must be a true no-op — identical output to no table at all");
}
#[test]
fn protrusion_never_shifts_a_mid_line_glyph_even_if_it_is_a_table_character() {
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let text = "First, middle, last.";
let runs = [StyledRun::new(text, font)];
let max_width = 1000.0; let shaper = CosmicShaper::headless();
let without = Paragraph::new(&runs, max_width);
let without_layout = layout_paragraph(&without, &shaper);
assert_eq!(without_layout.lines.len(), 1);
let table = ProtrusionTable::default_punctuation();
let with = Paragraph::new(&runs, max_width).with_protrusion(&table);
let with_layout = layout_paragraph(&with, &shaper);
assert_eq!(without_layout.glyphs.len(), with_layout.glyphs.len());
let last_i = without_layout.glyphs.len() - 1;
for i in 0..without_layout.glyphs.len() {
if i == 0 || i == last_i {
continue; }
assert_eq!(
without_layout.glyphs[i].x, with_layout.glyphs[i].x,
"glyph {i} ({:?}) is neither the first nor last glyph of its line — protrusion must never move it",
without_layout.glyphs[i].cluster
);
}
}
#[test]
fn protrusion_never_shifts_glyphs_on_the_paragraphs_own_ragged_final_line() {
use crate::linebreak::BreakStrategy;
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let text = "Good typography is invisible, or nearly so: a well-set \
paragraph reads evenly, without ragged holes or crowded lines. \
Hanging punctuation lets a period, comma, or hyphen protrude \
slightly past the measure, so the column's right-hand edge \
reads flush instead of ragged, line after line.";
let runs = [StyledRun::new(text, font)];
let max_width = 320.0;
let shaper = CosmicShaper::headless();
let without =
Paragraph::new(&runs, max_width).with_align(ParagraphAlign::Justify).with_break_strategy(BreakStrategy::KnuthPlass);
let without_layout = layout_paragraph(&without, &shaper);
assert!(without_layout.lines.len() > 3, "fixture must wrap to several lines");
let last_line_index = without_layout.lines.len() - 1;
let table = ProtrusionTable::default_punctuation();
let with = Paragraph::new(&runs, max_width)
.with_align(ParagraphAlign::Justify)
.with_break_strategy(BreakStrategy::KnuthPlass)
.with_protrusion(&table);
let with_layout = layout_paragraph(&with, &shaper);
assert_eq!(with_layout.lines.len(), without_layout.lines.len(), "protrusion must not change the line count");
let without_last_line: Vec<&GlyphLayout> = without_layout.glyphs.iter().filter(|g| g.line_index == last_line_index).collect();
let with_last_line: Vec<&GlyphLayout> = with_layout.glyphs.iter().filter(|g| g.line_index == last_line_index).collect();
assert_eq!(without_last_line.len(), with_last_line.len());
for (a, b) in without_last_line.iter().zip(with_last_line.iter()) {
assert_eq!(a.cluster, b.cluster);
assert_eq!(
a.x, b.x,
"the paragraph's own ragged final line must be BYTE-IDENTICAL between protrusion on/off — cluster {:?} moved from {} to {}",
a.cluster, a.x, b.x
);
}
let moved_on_an_earlier_line = (0..last_line_index).any(|line_index| {
let a = without_layout.glyphs.iter().filter(|g| g.line_index == line_index).last();
let b = with_layout.glyphs.iter().filter(|g| g.line_index == line_index).last();
matches!((a, b), (Some(a), Some(b)) if (a.x - b.x).abs() > 1e-6)
});
assert!(
moved_on_an_earlier_line,
"at least one non-final (justified) line's own trailing glyph must still protrude, or this test can't tell 'correctly gated' apart from 'silently broken'"
);
}
}