#[cfg(feature = "text_layout_hyphenation")]
use hyphenation::{Hyphenator, Standard};
#[cfg(not(feature = "text_layout_hyphenation"))]
use crate::text3::cache::Standard;
use crate::text3::cache::{
get_base_direction_from_logical, get_item_measure, is_word_separator, is_zero_width_space,
AvailableSpace, BidiDirection, JustifyContent, LayoutError, LoadedFonts,
LogicalItem, OverflowInfo, ParsedFontTrait, Point, PositionedItem,
ShapedItem, TextAlign, UnifiedConstraints, UnifiedLayout,
};
const INFINITY_BADNESS: f32 = 10000.0;
const SPACE_STRETCH_RATIO: f32 = 0.5;
const SPACE_SHRINK_RATIO: f32 = 0.33;
const HYPHENATION_PENALTY: f32 = 50.0;
const BADNESS_MULTIPLIER: f32 = 100.0;
#[derive(Debug, Clone)]
enum LayoutNode {
Box(ShapedItem, f32), Glue {
item: ShapedItem,
width: f32,
stretch: f32,
shrink: f32,
},
Penalty {
item: Option<ShapedItem>,
width: f32,
penalty: f32,
},
}
#[derive(Debug, Clone, Copy)]
struct Breakpoint {
demerit: f32,
previous: usize,
line: usize,
}
pub(crate) fn kp_layout<T: ParsedFontTrait>(
items: &[ShapedItem],
logical_items: &[LogicalItem],
constraints: &UnifiedConstraints,
hyphenator: Option<&Standard>,
fonts: &LoadedFonts<T>,
) -> UnifiedLayout {
if items.is_empty() {
return UnifiedLayout {
items: Vec::new(),
overflow: OverflowInfo::default(),
};
}
let nodes = convert_items_to_nodes(items, hyphenator, fonts);
let breaks = find_optimal_breakpoints(&nodes, constraints);
let final_layout: UnifiedLayout =
position_lines_from_breaks(&nodes, &breaks, logical_items, constraints);
final_layout
}
#[allow(clippy::too_many_lines)] fn convert_items_to_nodes<T: ParsedFontTrait>(
items: &[ShapedItem],
hyphenator: Option<&Standard>,
fonts: &LoadedFonts<T>,
) -> Vec<LayoutNode> {
let mut nodes = Vec::new();
let is_vertical = false; let mut item_iter = items.iter().peekable();
while let Some(item) = item_iter.next() {
match item {
item if is_zero_width_space(item) => {
nodes.push(LayoutNode::Penalty {
item: None,
width: 0.0,
penalty: 0.0,
});
}
item if is_word_separator(item) => {
let width = get_item_measure(item, is_vertical);
nodes.push(LayoutNode::Glue {
item: item.clone(),
width,
stretch: width * SPACE_STRETCH_RATIO,
shrink: width * SPACE_SHRINK_RATIO,
});
nodes.push(LayoutNode::Penalty {
item: None,
width: 0.0,
penalty: 0.0,
});
}
ShapedItem::Cluster(cluster)
if cluster.text.ends_with('\u{002D}')
|| cluster.text.ends_with('\u{2010}') =>
{
let width = get_item_measure(item, is_vertical);
nodes.push(LayoutNode::Box(item.clone(), width));
nodes.push(LayoutNode::Penalty {
item: None,
width: 0.0,
penalty: 0.0,
});
}
ShapedItem::Cluster(cluster) => {
let mut current_word_clusters = vec![cluster.clone()];
while let Some(peeked_item) = item_iter.peek() {
if let ShapedItem::Cluster(next_cluster) = peeked_item {
if is_word_separator(peeked_item)
|| is_zero_width_space(peeked_item)
|| next_cluster.text.ends_with('\u{002D}')
|| next_cluster.text.ends_with('\u{2010}')
{
break;
}
current_word_clusters.push(next_cluster.clone());
item_iter.next(); } else {
break;
}
}
let hyphenation_breaks = hyphenator.and_then(|h| {
crate::text3::cache::find_all_hyphenation_breaks(
¤t_word_clusters,
h,
is_vertical,
fonts,
)
});
if hyphenation_breaks.is_none() {
for c in current_word_clusters {
nodes.push(LayoutNode::Box(ShapedItem::Cluster(c.clone()), c.advance));
}
} else {
let breaks = hyphenation_breaks.unwrap();
let mut current_item_cursor = 0;
for b in &breaks {
let num_items_in_syllable = b.line_part.len() - current_item_cursor;
for item in b.line_part.iter().skip(current_item_cursor) {
nodes.push(LayoutNode::Box(
item.clone(),
get_item_measure(item, is_vertical),
));
}
current_item_cursor += num_items_in_syllable;
let hyphen_measure = get_item_measure(&b.hyphen_item, is_vertical);
nodes.push(LayoutNode::Penalty {
item: Some(b.hyphen_item.clone()),
width: hyphen_measure,
penalty: HYPHENATION_PENALTY, });
}
if let Some(last_break) = breaks.last() {
for remainder_item in &last_break.remainder_part {
nodes.push(LayoutNode::Box(
remainder_item.clone(),
get_item_measure(remainder_item, is_vertical),
));
}
} else {
for c in current_word_clusters {
nodes.push(LayoutNode::Box(ShapedItem::Cluster(c.clone()), c.advance));
}
}
}
}
ShapedItem::Object { .. } | ShapedItem::CombinedBlock { .. } => {
nodes.push(LayoutNode::Penalty {
item: None,
width: 0.0,
penalty: 0.0,
});
nodes.push(LayoutNode::Box(
item.clone(),
get_item_measure(item, is_vertical),
));
nodes.push(LayoutNode::Penalty {
item: None,
width: 0.0,
penalty: 0.0,
});
}
ShapedItem::Tab { bounds, .. } => {
nodes.push(LayoutNode::Glue {
item: item.clone(),
width: bounds.width,
stretch: bounds.width * SPACE_STRETCH_RATIO, shrink: bounds.width * SPACE_SHRINK_RATIO,
});
}
ShapedItem::Break { .. } => {
nodes.push(LayoutNode::Penalty {
item: None,
width: 0.0,
penalty: -INFINITY_BADNESS,
});
}
}
}
if !nodes.is_empty()
&& !matches!(
nodes.last(),
Some(LayoutNode::Penalty { penalty, .. }) if *penalty <= -INFINITY_BADNESS
)
{
nodes.push(LayoutNode::Penalty {
item: None,
width: 0.0,
penalty: -INFINITY_BADNESS,
});
}
nodes
}
#[allow(clippy::match_same_arms)] #[allow(clippy::too_many_lines)] #[allow(clippy::cognitive_complexity)] fn find_optimal_breakpoints(nodes: &[LayoutNode], constraints: &UnifiedConstraints) -> Vec<usize> {
if matches!(constraints.available_width, AvailableSpace::MinContent) {
let mut breaks = Vec::new();
for (i, node) in nodes.iter().enumerate() {
if matches!(node, LayoutNode::Penalty { .. }) {
breaks.push(i + 1);
}
}
if breaks.last() != Some(&nodes.len()) {
breaks.push(nodes.len());
}
return breaks;
}
let line_width = match constraints.available_width {
AvailableSpace::Definite(w) => w,
AvailableSpace::MaxContent => f32::MAX / 2.0,
AvailableSpace::MinContent => f32::MAX / 2.0,
};
let text_indent = constraints.text_indent;
let first_line_width = if constraints.text_indent_hanging {
line_width
} else {
line_width - text_indent
};
let non_first_line_width = if constraints.text_indent_hanging {
line_width - text_indent
} else {
line_width
};
let n = nodes.len();
let mut prefix_width = vec![0.0f32; n + 1];
let mut prefix_stretch = vec![0.0f32; n + 1];
let mut prefix_shrink = vec![0.0f32; n + 1];
for (k, node) in nodes.iter().enumerate() {
let (w, st, sh) = match node {
LayoutNode::Box(_, w) => (*w, 0.0, 0.0),
LayoutNode::Glue {
width,
stretch,
shrink,
..
} => (*width, *stretch, *shrink),
LayoutNode::Penalty { width, .. } => (*width, 0.0, 0.0),
};
prefix_width[k + 1] = prefix_width[k] + w;
prefix_stretch[k + 1] = prefix_stretch[k] + st;
prefix_shrink[k + 1] = prefix_shrink[k] + sh;
}
let mut breakpoints = vec![
Breakpoint {
demerit: INFINITY_BADNESS,
previous: 0,
line: 0
};
n + 1
];
breakpoints[0] = Breakpoint {
demerit: 0.0,
previous: 0,
line: 0,
};
for i in 0..n {
if !matches!(nodes.get(i), Some(LayoutNode::Penalty { .. })) {
continue;
}
for j in (0..=i).rev() {
let current_width = prefix_width[i + 1] - prefix_width[j];
let stretch = prefix_stretch[i + 1] - prefix_stretch[j];
let shrink = prefix_shrink[i + 1] - prefix_shrink[j];
let effective_line_width = if breakpoints[j].line == 0 {
first_line_width
} else if constraints.text_indent_hanging {
non_first_line_width
} else {
line_width
};
let ratio = if current_width < effective_line_width {
if stretch > 0.0 {
(effective_line_width - current_width) / stretch
} else {
INFINITY_BADNESS }
} else if current_width > effective_line_width {
if shrink > 0.0 {
(effective_line_width - current_width) / shrink
} else {
-INFINITY_BADNESS
}
} else {
0.0 };
if ratio < -1.0 {
continue;
}
let mut badness = BADNESS_MULTIPLIER * ratio.abs().powi(3);
if let Some(LayoutNode::Penalty { penalty, .. }) = nodes.get(i) {
if *penalty >= 0.0 {
badness += penalty;
} else if *penalty <= -INFINITY_BADNESS {
badness = -INFINITY_BADNESS; }
}
let demerit = badness + breakpoints[j].demerit;
if demerit < breakpoints[i + 1].demerit {
breakpoints[i + 1] = Breakpoint {
demerit,
previous: j,
line: breakpoints[j].line + 1,
};
}
}
}
let mut breaks = Vec::new();
let mut current = nodes.len();
while current > 0 {
breaks.push(current);
let prev_idx = breakpoints[current].previous;
current = prev_idx;
}
breaks.reverse();
breaks
}
#[allow(clippy::suboptimal_flops)] #[allow(clippy::cast_precision_loss)] #[allow(clippy::match_same_arms)] #[allow(clippy::too_many_lines)] fn position_lines_from_breaks(
nodes: &[LayoutNode],
breaks: &[usize],
logical_items: &[LogicalItem],
constraints: &UnifiedConstraints,
) -> UnifiedLayout {
let mut positioned_items = Vec::new();
let mut start_node = 0;
let mut cross_axis_pen = 0.0;
let base_direction = get_base_direction_from_logical(logical_items);
for (line_index, &end_node) in breaks.iter().enumerate() {
let line_nodes = &nodes[start_node..end_node];
let is_last_line = line_index == breaks.len() - 1;
let mut line_items: Vec<ShapedItem> = line_nodes
.iter()
.filter_map(|node| match node {
LayoutNode::Box(item, _) => Some(item.clone()),
LayoutNode::Glue { item, .. } => Some(item.clone()),
LayoutNode::Penalty { item, .. } => item.clone(),
})
.collect();
while line_items.last().is_some_and(is_word_separator) {
line_items.pop();
}
let mut extra_per_space = 0.0;
let line_width: f32 = line_items.iter().map(|i| get_item_measure(i, false)).sum();
let ends_with_forced_break = line_nodes.iter().any(|n| matches!(
n, LayoutNode::Penalty { penalty, .. } if *penalty <= -INFINITY_BADNESS
));
let effective_align = super::cache::resolve_effective_alignment(
constraints.text_align,
constraints.text_align_last,
is_last_line || ends_with_forced_break,
);
let should_justify = constraints.text_justify != JustifyContent::None
&& (!is_last_line || constraints.text_align == TextAlign::JustifyAll
|| effective_align == TextAlign::Justify || effective_align == TextAlign::JustifyAll);
let available_width_f32 = match constraints.available_width {
AvailableSpace::Definite(w) => w,
AvailableSpace::MaxContent => line_width,
AvailableSpace::MinContent => line_width,
};
if should_justify {
let space_to_add = available_width_f32 - line_width;
if space_to_add > 0.0 {
let space_count = line_items
.iter()
.filter(|item| is_word_separator(item))
.count();
if space_count > 0 {
extra_per_space = space_to_add / space_count as f32;
}
}
}
let total_width: f32 = line_items
.iter()
.map(|item| get_item_measure(item, false))
.sum();
let is_indefinite = matches!(
constraints.available_width,
AvailableSpace::MaxContent | AvailableSpace::MinContent
);
let remaining_space = if is_indefinite {
0.0
} else {
available_width_f32
- (total_width
+ extra_per_space
* line_items
.iter()
.filter(|item| is_word_separator(item))
.count() as f32)
};
let physical_align = match (effective_align, base_direction) {
(TextAlign::Start, BidiDirection::Ltr) => TextAlign::Left,
(TextAlign::Start, BidiDirection::Rtl) => TextAlign::Right,
(TextAlign::End, BidiDirection::Ltr) => TextAlign::Right,
(TextAlign::End, BidiDirection::Rtl) => TextAlign::Left,
(other, _) => other,
};
let mut main_axis_pen = if remaining_space < 0.0 {
0.0
} else {
match physical_align {
TextAlign::Center => remaining_space / 2.0,
TextAlign::Right => remaining_space,
_ => 0.0,
}
};
if constraints.text_indent != 0.0 {
let is_indent_target = line_index == 0;
let should_indent = if constraints.text_indent_hanging {
!is_indent_target
} else {
is_indent_target
};
if should_indent {
main_axis_pen += constraints.text_indent;
}
}
for item in line_items {
let item_advance = get_item_measure(&item, false);
let draw_pos = match &item {
ShapedItem::Cluster(c) if !c.glyphs.is_empty() => {
let glyph = &c.glyphs[0];
Point {
x: main_axis_pen + glyph.offset.x,
y: cross_axis_pen - glyph.offset.y, }
}
_ => Point {
x: main_axis_pen,
y: cross_axis_pen,
},
};
positioned_items.push(PositionedItem {
item: item.clone(),
position: draw_pos,
line_index,
});
main_axis_pen += item_advance;
if is_word_separator(&item) {
main_axis_pen += extra_per_space;
}
}
cross_axis_pen += constraints.resolved_line_height();
start_node = end_node;
}
let mut layout = UnifiedLayout {
items: positioned_items,
overflow: OverflowInfo::default(),
};
let bounds = layout.bounds();
layout.overflow.unclipped_bounds = bounds;
layout
}
#[cfg(test)]
mod kp_fix_tests {
use super::*;
use crate::text3::cache::{ShapedCluster, StyleProperties, UnifiedConstraints};
use azul_core::selection::{ContentIndex, GraphemeClusterId};
use azul_css::props::basic::FontRef;
use std::sync::Arc;
fn cl(text: &str, advance: f32) -> ShapedItem {
ShapedItem::Cluster(ShapedCluster {
text: text.to_string(),
source_cluster_id: GraphemeClusterId { source_run: 0, start_byte_in_run: 0 },
source_content_index: ContentIndex { run_index: 0, item_index: 0 },
source_node_id: None,
glyphs: smallvec::SmallVec::new(),
advance,
direction: BidiDirection::Ltr,
style: Arc::new(StyleProperties::default()),
marker_position_outside: None,
is_first_fragment: true,
is_last_fragment: true,
})
}
fn nodes_for(text: &str) -> Vec<LayoutNode> {
let items: Vec<ShapedItem> = text
.chars()
.map(|c| {
let s = c.to_string();
let adv = match c {
' ' => 5.0,
'-' => 6.0,
_ => 12.0,
};
cl(&s, adv)
})
.collect();
let fonts: LoadedFonts<FontRef> = LoadedFonts::new();
convert_items_to_nodes(&items, None, &fonts)
}
#[test]
fn bug1_terminal_break_wraps_word_ending_paragraph() {
let nodes = nodes_for("aaaa aaaa");
assert!(matches!(nodes.last(), Some(LayoutNode::Penalty { penalty, .. }) if *penalty <= -INFINITY_BADNESS),
"a terminal forced break must be appended");
let c = UnifiedConstraints { available_width: AvailableSpace::Definite(60.0), ..Default::default() };
let breaks = find_optimal_breakpoints(&nodes, &c);
assert!(breaks.len() >= 2, "must break into >=2 lines, got {breaks:?}");
assert_eq!(*breaks.last().unwrap(), nodes.len(), "final break at n");
}
#[test]
fn bug2_hyphen_is_break_opportunity() {
let nodes = nodes_for("aaaa-aaaa");
let mut found = false;
for (i, n) in nodes.iter().enumerate() {
if let LayoutNode::Box(ShapedItem::Cluster(cc), _) = n {
if cc.text == "-" {
match nodes.get(i + 1) {
Some(LayoutNode::Penalty { penalty, width, .. }) => {
assert!(*width == 0.0 && *penalty > -INFINITY_BADNESS,
"hyphen must be followed by a zero-width soft penalty");
found = true;
}
other => panic!("expected penalty after hyphen, got {other:?}"),
}
}
}
}
assert!(found, "hyphen box must exist");
let c = UnifiedConstraints { available_width: AvailableSpace::Definite(60.0), ..Default::default() };
let breaks = find_optimal_breakpoints(&nodes, &c);
assert!(breaks.len() >= 2, "hyphenated token must wrap, got {breaks:?}");
}
#[test]
fn bug4_min_content_breaks_every_word() {
let nodes = nodes_for("aaaa aaaa");
let c = UnifiedConstraints { available_width: AvailableSpace::MinContent, ..Default::default() };
let breaks = find_optimal_breakpoints(&nodes, &c);
assert!(breaks.len() >= 2, "min-content must break per word, got {breaks:?}");
}
#[test]
fn bug3_trailing_space_trimmed_from_line() {
let nodes = nodes_for("aaaa aaaa");
let c = UnifiedConstraints {
available_width: AvailableSpace::Definite(60.0),
..Default::default()
};
let breaks = find_optimal_breakpoints(&nodes, &c);
let layout = position_lines_from_breaks(&nodes, &breaks, &[], &c);
let line0_spaces = layout
.items
.iter()
.filter(|it| it.line_index == 0 && is_word_separator(&it.item))
.count();
assert_eq!(line0_spaces, 0, "trailing space must be trimmed from line 0");
let max_x = layout
.items
.iter()
.filter(|it| it.line_index == 0)
.filter_map(|it| it.item.as_cluster().map(|cc| it.position.x + cc.advance))
.fold(0.0f32, f32::max);
assert!((max_x - 48.0).abs() < 0.01, "line 0 right edge {max_x} should be 48px");
}
}