use crate::area::TraitSet;
use fop_types::{FontRegistry, Length};
#[derive(Debug, Clone)]
pub struct Breakpoint {
pub position: usize,
pub width: Length,
pub penalty: i32,
pub forced: bool,
}
#[derive(Debug, Clone)]
struct Box {
width: Length,
text: String,
}
#[derive(Debug, Clone)]
#[allow(dead_code)] struct Glue {
width: Length,
stretch: Length,
shrink: Length,
}
#[derive(Debug, Clone)]
#[allow(dead_code)] struct Penalty {
penalty: i32,
width: Length,
forced: bool,
}
#[derive(Debug, Clone)]
#[allow(dead_code)] enum Item {
Box(Box),
Glue(Glue),
Penalty(Penalty),
}
#[derive(Debug, Clone)]
struct Node {
demerits: f64,
line: usize,
previous: Option<usize>,
position: usize,
}
pub struct KnuthPlassBreaker {
line_width: Length,
font_registry: FontRegistry,
tolerance: u32,
}
impl KnuthPlassBreaker {
pub fn new(line_width: Length) -> Self {
Self {
line_width,
font_registry: FontRegistry::new(),
tolerance: 2, }
}
pub fn with_tolerance(mut self, tolerance: u32) -> Self {
self.tolerance = tolerance.min(10);
self
}
pub fn break_text(&self, text: &str, traits: &TraitSet) -> Vec<String> {
let items = self.text_to_items(text, traits);
let breakpoints = self.find_breakpoints(&items);
self.items_to_lines(&items, &breakpoints)
}
fn text_to_items(&self, text: &str, traits: &TraitSet) -> Vec<Item> {
let mut items = Vec::new();
let font_size = traits.font_size.unwrap_or(Length::from_pt(12.0));
let font_name = traits.font_family.as_deref().unwrap_or("Helvetica");
let font_metrics = self.font_registry.get_or_default(font_name);
let words: Vec<&str> = text.split_whitespace().collect();
for (i, word) in words.iter().enumerate() {
let width = font_metrics.measure_text(word, font_size);
items.push(Item::Box(Box {
width,
text: (*word).to_string(),
}));
if i < words.len() - 1 {
let space_width = font_metrics.measure_text(" ", font_size);
items.push(Item::Glue(Glue {
width: space_width,
stretch: Length::from_pt(space_width.to_pt() * 0.5), shrink: Length::from_pt(space_width.to_pt() * 0.33), }));
items.push(Item::Penalty(Penalty {
penalty: 0, width: Length::ZERO,
forced: false,
}));
}
}
items.push(Item::Penalty(Penalty {
penalty: -10000, width: Length::ZERO,
forced: true,
}));
items
}
fn find_breakpoints(&self, items: &[Item]) -> Vec<usize> {
let mut nodes = vec![Node {
demerits: 0.0,
line: 0,
previous: None,
position: 0,
}];
let mut active_nodes = vec![0];
for (i, item) in items.iter().enumerate() {
if let Item::Penalty(_) = item {
let mut new_active = Vec::new();
for &active_idx in &active_nodes {
let active = &nodes[active_idx];
let width = self.calculate_width(items, active.position, i);
let max_width = Length::from_pt(self.line_width.to_pt() * 1.5);
if width <= max_width {
let ratio = (width.to_pt() / self.line_width.to_pt() - 1.0).abs();
let demerits = active.demerits + ratio.powi(2) * 100.0;
nodes.push(Node {
demerits,
line: active.line + 1,
previous: Some(active_idx),
position: i + 1,
});
new_active.push(nodes.len() - 1);
}
}
if !new_active.is_empty() {
active_nodes = new_active;
}
}
}
let best_node_idx = active_nodes
.iter()
.min_by(|&&a, &&b| {
nodes[a]
.demerits
.partial_cmp(&nodes[b].demerits)
.unwrap_or(std::cmp::Ordering::Equal)
})
.copied()
.unwrap_or(0);
let mut breakpoints = Vec::new();
let mut current = Some(best_node_idx);
while let Some(idx) = current {
breakpoints.push(nodes[idx].position);
current = nodes[idx].previous;
}
breakpoints.reverse();
breakpoints
}
fn calculate_width(&self, items: &[Item], start: usize, end: usize) -> Length {
let mut width = Length::ZERO;
for item in &items[start..end] {
match item {
Item::Box(b) => width += b.width,
Item::Glue(g) => width += g.width,
Item::Penalty(_) => {}
}
}
width
}
fn items_to_lines(&self, items: &[Item], breakpoints: &[usize]) -> Vec<String> {
let mut lines = Vec::new();
let mut start = 0;
for &end in breakpoints {
let mut line = String::new();
for item in &items[start..end] {
if let Item::Box(b) = item {
if !line.is_empty() {
line.push(' ');
}
line.push_str(&b.text);
}
}
if !line.is_empty() {
lines.push(line);
}
start = end;
}
lines
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_breaker_default_tolerance() {
let breaker = KnuthPlassBreaker::new(Length::from_pt(200.0));
assert_eq!(breaker.tolerance, 2);
}
#[test]
fn test_breaker_line_width_stored() {
let breaker = KnuthPlassBreaker::new(Length::from_pt(300.0));
assert_eq!(breaker.line_width, Length::from_pt(300.0));
}
#[test]
fn test_with_tolerance_sets_value() {
let breaker = KnuthPlassBreaker::new(Length::from_pt(200.0)).with_tolerance(5);
assert_eq!(breaker.tolerance, 5);
}
#[test]
fn test_tolerance_clamped_to_ten() {
let breaker = KnuthPlassBreaker::new(Length::from_pt(200.0)).with_tolerance(100);
assert_eq!(breaker.tolerance, 10);
}
#[test]
fn test_tolerance_zero_allowed() {
let breaker = KnuthPlassBreaker::new(Length::from_pt(200.0)).with_tolerance(0);
assert_eq!(breaker.tolerance, 0);
}
#[test]
fn test_tolerance_exactly_ten_not_clamped() {
let breaker = KnuthPlassBreaker::new(Length::from_pt(200.0)).with_tolerance(10);
assert_eq!(breaker.tolerance, 10);
}
#[test]
fn test_breakpoint_construction() {
let bp = Breakpoint {
position: 5,
width: Length::from_pt(50.0),
penalty: 10,
forced: false,
};
assert_eq!(bp.position, 5);
assert_eq!(bp.width, Length::from_pt(50.0));
assert_eq!(bp.penalty, 10);
assert!(!bp.forced);
}
#[test]
fn test_forced_breakpoint() {
let bp = Breakpoint {
position: 0,
width: Length::ZERO,
penalty: -10000,
forced: true,
};
assert!(bp.forced);
assert!(bp.penalty < 0);
}
#[test]
fn test_inhibited_break_high_penalty() {
let bp = Breakpoint {
position: 3,
width: Length::from_pt(30.0),
penalty: 10000,
forced: false,
};
assert!(bp.penalty > 0);
assert!(!bp.forced);
}
#[test]
fn test_short_paragraph_all_words_present() {
let breaker = KnuthPlassBreaker::new(Length::from_pt(300.0));
let traits = TraitSet::default();
let lines = breaker.break_text("Hello world", &traits);
assert!(!lines.is_empty());
let joined = lines.join(" ");
assert!(joined.contains("Hello"));
assert!(joined.contains("world"));
}
#[test]
fn test_single_word_produces_one_line() {
let breaker = KnuthPlassBreaker::new(Length::from_pt(200.0));
let traits = TraitSet::default();
let lines = breaker.break_text("Hello", &traits);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0], "Hello");
}
#[test]
fn test_medium_paragraph_two_or_three_lines() {
let breaker = KnuthPlassBreaker::new(Length::from_pt(100.0));
let traits = TraitSet::default();
let text = "The quick brown fox jumps over the lazy dog near the river";
let lines = breaker.break_text(text, &traits);
assert!(lines.len() >= 2);
}
#[test]
fn test_medium_paragraph_all_words_preserved() {
let breaker = KnuthPlassBreaker::new(Length::from_pt(120.0));
let traits = TraitSet::default();
let text = "alpha beta gamma delta epsilon zeta eta theta";
let lines = breaker.break_text(text, &traits);
let all_words: Vec<String> = lines
.iter()
.flat_map(|l| l.split_whitespace().map(|s| s.to_string()))
.collect();
let expected: Vec<String> = text.split_whitespace().map(|s| s.to_string()).collect();
assert_eq!(all_words, expected);
}
#[test]
fn test_tight_paragraph_many_lines() {
let breaker = KnuthPlassBreaker::new(Length::from_pt(60.0));
let traits = TraitSet::default();
let text = "This is a very long text that will need many line breaks to fit";
let lines = breaker.break_text(text, &traits);
assert!(lines.len() > 2);
}
#[test]
fn test_empty_text_produces_no_lines() {
let breaker = KnuthPlassBreaker::new(Length::from_pt(200.0));
let traits = TraitSet::default();
let lines = breaker.break_text("", &traits);
assert!(lines.is_empty());
}
#[test]
fn test_single_very_long_word_does_not_panic() {
let breaker = KnuthPlassBreaker::new(Length::from_pt(50.0));
let traits = TraitSet::default();
let _lines = breaker.break_text("Supercalifragilisticexpialidocious", &traits);
}
#[test]
fn test_text_to_items_produces_items_for_two_words() {
let breaker = KnuthPlassBreaker::new(Length::from_pt(200.0));
let traits = TraitSet::default();
let items = breaker.text_to_items("hello world", &traits);
assert_eq!(items.len(), 5);
}
#[test]
fn test_text_to_items_single_word() {
let breaker = KnuthPlassBreaker::new(Length::from_pt(200.0));
let traits = TraitSet::default();
let items = breaker.text_to_items("hello", &traits);
assert_eq!(items.len(), 2);
}
#[test]
fn test_text_to_items_three_words() {
let breaker = KnuthPlassBreaker::new(Length::from_pt(200.0));
let traits = TraitSet::default();
let items = breaker.text_to_items("one two three", &traits);
assert_eq!(items.len(), 8);
}
#[test]
fn test_calculate_width_boxes_only() {
let breaker = KnuthPlassBreaker::new(Length::from_pt(200.0));
let traits = TraitSet::default();
let items = breaker.text_to_items("hello world", &traits);
let w = breaker.calculate_width(&items, 0, 1);
assert!(w > Length::ZERO);
}
#[test]
fn test_calculate_width_zero_range() {
let breaker = KnuthPlassBreaker::new(Length::from_pt(200.0));
let traits = TraitSet::default();
let items = breaker.text_to_items("hello", &traits);
let w = breaker.calculate_width(&items, 0, 0);
assert_eq!(w, Length::ZERO);
}
#[test]
fn test_narrower_line_width_produces_more_lines() {
let traits = TraitSet::default();
let text = "one two three four five six seven eight nine ten";
let breaker_wide = KnuthPlassBreaker::new(Length::from_pt(300.0));
let lines_wide = breaker_wide.break_text(text, &traits);
let breaker_narrow = KnuthPlassBreaker::new(Length::from_pt(80.0));
let lines_narrow = breaker_narrow.break_text(text, &traits);
assert!(lines_narrow.len() >= lines_wide.len());
}
#[test]
fn test_larger_font_size_produces_more_lines() {
let text = "one two three four five six seven eight";
let traits_small = TraitSet {
font_size: Some(Length::from_pt(8.0)),
..Default::default()
};
let traits_large = TraitSet {
font_size: Some(Length::from_pt(18.0)),
..Default::default()
};
let breaker = KnuthPlassBreaker::new(Length::from_pt(120.0));
let lines_small = breaker.break_text(text, &traits_small);
let lines_large = breaker.break_text(text, &traits_large);
assert!(lines_large.len() >= lines_small.len());
}
#[test]
fn test_items_to_lines_reconstructs_text() {
let breaker = KnuthPlassBreaker::new(Length::from_pt(400.0));
let traits = TraitSet::default();
let text = "hello world foo bar";
let lines = breaker.break_text(text, &traits);
let reconstructed: String = lines.join(" ");
for word in text.split_whitespace() {
assert!(reconstructed.contains(word));
}
}
#[test]
fn test_zero_penalty_is_good_break() {
let bp = Breakpoint {
position: 5,
width: Length::from_pt(40.0),
penalty: 0,
forced: false,
};
assert_eq!(bp.penalty, 0);
}
#[test]
fn test_negative_penalty_is_forced_break() {
let bp = Breakpoint {
position: 10,
width: Length::ZERO,
penalty: -10000,
forced: true,
};
assert!(bp.penalty < 0);
assert!(bp.forced);
}
}