use super::hyphen::{HyphenationContext, try_break_word, try_hyphenate};
use super::shape::WordToken;
#[derive(Clone, Copy)]
pub(in crate::compile) struct LineMetrics {
pub(in crate::compile) space_advance: f64,
pub(in crate::compile) min_line_width: f64,
pub(in crate::compile) line_height: f64,
}
#[derive(Clone, Copy)]
pub(in crate::compile) struct LineStyle {
pub(in crate::compile) ascent: f64,
pub(in crate::compile) space_advance: f64,
pub(in crate::compile) font_size: f32,
pub(in crate::compile) deco_thickness: f64,
}
#[derive(Clone, Copy)]
pub(in crate::compile) enum LineDecoration {
Background(crate::ir::Color),
Rule {
color: crate::ir::Color,
thickness: f64,
},
}
pub(in crate::compile) struct Line {
pub(in crate::compile) words: Vec<WordToken>,
pub(in crate::compile) content_w: f64,
pub(in crate::compile) paragraph: usize,
pub(in crate::compile) height_px: f64,
pub(in crate::compile) line_style: Option<LineStyle>,
pub(in crate::compile) left_indent_px: f64,
pub(in crate::compile) decoration: Option<LineDecoration>,
}
pub(in crate::compile) fn pack_lines(
tokens: Vec<WordToken>,
box_w: f64,
space_advance: f64,
hyph: Option<&HyphenationContext>,
line_height: f64,
) -> Vec<Line> {
let mut forced_break = false;
pack_lines_core(
tokens,
|_| box_w,
LineMetrics {
space_advance,
min_line_width: f64::NEG_INFINITY,
line_height,
},
hyph,
usize::MAX,
&mut forced_break,
)
}
pub(in crate::compile) fn pack_lines_reporting(
tokens: Vec<WordToken>,
box_w: f64,
space_advance: f64,
hyph: Option<&HyphenationContext>,
forced_break: &mut bool,
line_height: f64,
) -> Vec<Line> {
pack_lines_core(
tokens,
|_| box_w,
LineMetrics {
space_advance,
min_line_width: f64::NEG_INFINITY,
line_height,
},
hyph,
usize::MAX,
forced_break,
)
}
pub(in crate::compile) fn pack_lines_runaround(
tokens: Vec<WordToken>,
band_width: impl Fn(usize) -> f64,
space_advance: f64,
min_line_width: f64,
max_lines: usize,
line_height: f64,
) -> Vec<Line> {
let mut forced_break = false;
pack_lines_core(
tokens,
band_width,
LineMetrics {
space_advance,
min_line_width,
line_height,
},
None,
max_lines,
&mut forced_break,
)
}
#[derive(Clone, Copy)]
pub(in crate::compile) struct WidthProfile {
pub(in crate::compile) narrow_w: f64,
pub(in crate::compile) narrow_count: usize,
pub(in crate::compile) full_w: f64,
}
impl WidthProfile {
fn width_for(&self, line_index: usize) -> f64 {
if line_index < self.narrow_count {
self.narrow_w
} else {
self.full_w
}
}
}
pub(in crate::compile) fn pack_lines_variable(
tokens: Vec<WordToken>,
profile: WidthProfile,
space_advance: f64,
line_height: f64,
) -> Vec<Line> {
let mut forced_break = false;
pack_lines_core(
tokens,
|i| profile.width_for(i),
LineMetrics {
space_advance,
min_line_width: f64::NEG_INFINITY,
line_height,
},
None,
usize::MAX,
&mut forced_break,
)
}
pub(in crate::compile) fn pack_lines_core(
tokens: Vec<WordToken>,
width_for: impl Fn(usize) -> f64,
metrics: LineMetrics,
hyph: Option<&HyphenationContext>,
max_lines: usize,
forced_break: &mut bool,
) -> Vec<Line> {
let LineMetrics {
space_advance,
min_line_width,
line_height,
} = metrics;
let mut lines: Vec<Line> = Vec::new();
let mut cur: Vec<WordToken> = Vec::new();
let mut line_w: f64 = 0.0;
let mut cur_para: usize = 0;
let mut queue: std::collections::VecDeque<WordToken> = tokens.into();
while let Some(tok) = queue.pop_front() {
if cur.is_empty() {
while width_for(lines.len()) < min_line_width {
if lines.len() >= max_lines {
return lines;
}
lines.push(Line {
words: Vec::new(),
content_w: 0.0,
paragraph: tok.src.paragraph,
height_px: line_height,
line_style: None,
left_indent_px: 0.0,
decoration: None,
});
}
}
let box_w = width_for(lines.len());
let para_break = !cur.is_empty() && tok.src.paragraph != cur_para;
let lead_gap = if tok.glued { 0.0 } else { space_advance };
let overflow = !cur.is_empty() && line_w + lead_gap + tok.advance > box_w;
if overflow && !para_break {
if let Some(ctx) = hyph {
let avail = box_w - line_w - lead_gap;
if avail > 0.0
&& let Some(split) = try_hyphenate(&tok, avail, ctx)
{
line_w += lead_gap + split.head.advance;
cur.push(split.head);
lines.push(Line {
words: std::mem::take(&mut cur),
content_w: line_w,
paragraph: cur_para,
height_px: line_height,
line_style: None,
left_indent_px: 0.0,
decoration: None,
});
line_w = 0.0;
queue.push_front(split.tail);
continue;
}
}
}
if cur.is_empty()
&& tok.advance > box_w
&& let Some(ctx) = hyph
&& ctx.break_word
{
if lines.len() >= max_lines {
let advance = tok.advance;
let paragraph = tok.src.paragraph;
lines.push(Line {
words: vec![tok],
content_w: advance,
paragraph,
height_px: line_height,
line_style: None,
left_indent_px: 0.0,
decoration: None,
});
return lines;
}
if let Some((head, tail)) = try_break_word(&tok, box_w, ctx) {
*forced_break = true;
let head_para = head.src.paragraph;
let head_advance = head.advance;
lines.push(Line {
words: vec![head],
content_w: head_advance,
paragraph: head_para,
height_px: line_height,
line_style: None,
left_indent_px: 0.0,
decoration: None,
});
queue.push_front(tail);
continue;
}
}
if overflow || para_break {
let content_w = line_w;
lines.push(Line {
words: std::mem::take(&mut cur),
content_w,
paragraph: cur_para,
height_px: line_height,
line_style: None,
left_indent_px: 0.0,
decoration: None,
});
line_w = 0.0;
queue.push_front(tok);
continue;
}
if cur.is_empty() {
cur_para = tok.src.paragraph;
}
let gap = if cur.is_empty() { 0.0 } else { lead_gap };
line_w += gap + tok.advance;
cur.push(tok);
}
if !cur.is_empty() {
lines.push(Line {
words: cur,
content_w: line_w,
paragraph: cur_para,
height_px: line_height,
line_style: None,
left_indent_px: 0.0,
decoration: None,
});
}
lines
}
#[cfg(test)]
mod packer_tests {
use super::*;
use zenith_core::FontStyle;
use zenith_layout::ZenithGlyphRun;
use super::super::shape::{WordSource, WordToken};
use crate::ir::Color;
fn word(advance: f64) -> WordToken {
WordToken {
runs: vec![ZenithGlyphRun {
font_id: "test-font".to_owned(),
font_size: 16.0,
ascent: 12.0,
descent: 4.0,
line_height: 18.0,
advance_width: advance as f32,
glyphs: Vec::new(),
}],
advance,
color: Color::srgb(0, 0, 0, 255),
underline: false,
strikethrough: false,
highlight: None,
code: false,
link: None,
baseline_dy: 0.0,
glued: false,
src: WordSource {
text: String::new(),
weight: 400,
style: FontStyle::Normal,
font_size: 16.0,
paragraph: 0,
hyphen_part: None,
},
}
}
fn tokens(advances: &[f64]) -> Vec<WordToken> {
advances.iter().copied().map(word).collect()
}
fn shape(lines: &[Line]) -> Vec<(f64, Vec<f64>)> {
lines
.iter()
.map(|l| (l.content_w, l.words.iter().map(|w| w.advance).collect()))
.collect()
}
#[test]
fn pack_uniform_byte_identical_after_refactor() {
let box_w = 70.0;
let space = 5.0;
let advances = [10.0, 20.0, 30.0, 40.0, 15.0];
let packed = pack_lines(tokens(&advances), box_w, space, None, 18.0);
assert_eq!(
shape(&packed),
vec![(70.0, vec![10.0, 20.0, 30.0]), (60.0, vec![40.0, 15.0]),],
"uniform packing must be unchanged by the closure refactor"
);
}
#[test]
fn runaround_blocked_band_emits_empty_line() {
let band = |i: usize| if i == 0 { 0.0 } else { 100.0 };
let lines = pack_lines_runaround(tokens(&[10.0, 20.0]), band, 5.0, 1.0, 16, 18.0);
assert_eq!(
shape(&lines),
vec![(0.0, vec![]), (35.0, vec![10.0, 20.0])],
"a blocked band must emit an empty line then flow below it"
);
}
#[test]
fn runaround_narrow_band_breaks_more() {
let lines = pack_lines_runaround(tokens(&[10.0, 20.0, 30.0]), |_| 30.0, 5.0, 1.0, 64, 18.0);
assert_eq!(
shape(&lines),
vec![(10.0, vec![10.0]), (20.0, vec![20.0]), (30.0, vec![30.0])],
);
}
#[test]
fn uniform_height_px_cumulative_equals_index_times_line_height() {
let line_height = 18.0_f64;
let space = 5.0;
let advances = [10.0_f64, 20.0, 30.0, 15.0, 25.0];
let lines = pack_lines(tokens(&advances), 70.0, space, None, line_height);
assert!(
!lines.is_empty(),
"test requires at least one line to be meaningful"
);
for (i, line) in lines.iter().enumerate() {
assert_eq!(
line.height_px, line_height,
"line {i}: height_px must equal the uniform line_height"
);
let cumulative: f64 = lines[..i].iter().map(|l| l.height_px).sum();
let by_index = (i as f64) * line_height;
assert_eq!(
cumulative, by_index,
"line {i}: cumulative sum ({cumulative}) must equal index*line_height ({by_index})"
);
}
}
#[test]
fn runaround_all_blocked_respects_max_lines() {
let lines = pack_lines_runaround(tokens(&[10.0, 20.0]), |_| 0.0, 5.0, 1.0, 3, 18.0);
assert_eq!(lines.len(), 3, "blocked tail must be capped at max_lines");
assert!(
lines.iter().all(|l| l.words.is_empty()),
"all capped lines are empty (words clipped)"
);
}
}