use crate::layout::greedy::{Atom, AtomGlyph, TextAtom};
use crate::linebreak::{Hyphenation, Lang};
fn lang_for(hyphenation: Hyphenation) -> Option<Lang> {
match hyphenation {
Hyphenation::None => None,
Hyphenation::English => Some(Lang::English),
Hyphenation::Russian => Some(Lang::Russian),
Hyphenation::Lang(lang) => Some(lang),
}
}
pub(crate) fn hyphenation_points(word: &str, lang: Lang, left_min: usize, right_min: usize) -> Vec<usize> {
let char_count = word.chars().count();
if char_count < left_min + right_min || !word.chars().all(char::is_alphabetic) {
return Vec::new();
}
let syllables = hypher::hyphenate_bounded(word, lang, left_min, right_min);
let mut points = Vec::new();
let mut consumed = 0usize;
for (i, syllable) in syllables.enumerate() {
if i > 0 {
points.push(consumed);
}
consumed += syllable.len();
}
points
}
pub(crate) fn expand_hyphenation(atoms: Vec<Atom>, hyphenation: Hyphenation, left_min: usize, right_min: usize) -> Vec<Atom> {
let Some(lang) = lang_for(hyphenation) else {
return atoms;
};
let mut out = Vec::with_capacity(atoms.len());
for atom in atoms {
match atom {
Atom::Text(t) if !t.is_glue => out.extend(split_atom(t, lang, left_min, right_min)),
other => out.push(other),
}
}
out
}
fn glyph_index_for_offset(glyphs: &[AtomGlyph], offset: usize) -> Option<usize> {
let mut acc = 0usize;
for (i, g) in glyphs.iter().enumerate() {
if acc == offset {
return Some(i);
}
acc += g.cluster.len();
}
if acc == offset {
Some(glyphs.len())
} else {
None
}
}
fn split_atom(atom: TextAtom, lang: Lang, left_min: usize, right_min: usize) -> Vec<Atom> {
let word: String = atom.glyphs.iter().map(|g| g.cluster.as_str()).collect();
let points = hyphenation_points(&word, lang, left_min, right_min);
if points.is_empty() {
return vec![Atom::Text(atom)];
}
let mut cuts: Vec<usize> = points
.iter()
.filter_map(|&offset| glyph_index_for_offset(&atom.glyphs, offset))
.filter(|&i| i > 0 && i < atom.glyphs.len())
.collect();
cuts.sort_unstable();
cuts.dedup();
if cuts.is_empty() {
return vec![Atom::Text(atom)];
}
let mut fragments = Vec::with_capacity(cuts.len() + 1);
let mut start = 0usize;
for &cut in &cuts {
fragments.push(make_fragment(&atom, start, cut, true));
start = cut;
}
fragments.push(make_fragment(&atom, start, atom.glyphs.len(), false));
fragments
}
fn make_fragment(atom: &TextAtom, start: usize, end: usize, hyphen_break: bool) -> Atom {
let slice = &atom.glyphs[start..end];
let base_x = slice.first().map(|g| g.x).unwrap_or(0.0);
let glyphs: Vec<AtomGlyph> = slice
.iter()
.map(|g| AtomGlyph { cluster: g.cluster.clone(), x: g.x - base_x, y_offset: g.y_offset, advance: g.advance, width: g.width })
.collect();
let width = glyphs.last().map(|g| g.x + g.advance).unwrap_or(0.0);
Atom::Text(TextAtom {
run_index: atom.run_index,
glyphs,
width,
ascent: atom.ascent,
descent: atom.descent,
shape_font: atom.shape_font,
is_glue: false,
hyphen_break,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::layout::greedy::build_atom_stream;
use crate::linebreak::LineBreakParams;
use crate::model::{FontSpec, Paragraph, StyledRun};
use crate::shape::CosmicShaper;
use uzor::fonts::FontFamily;
const LEFT_MIN: usize = 2;
const RIGHT_MIN: usize = 3;
#[test]
fn default_left_min_and_right_min_match_line_break_params_default() {
let p = LineBreakParams::default();
assert_eq!(p.left_min, LEFT_MIN);
assert_eq!(p.right_min, RIGHT_MIN);
}
#[test]
fn hyphenation_of_hyphenation_itself_matches_real_hypher_break_points() {
assert_eq!(hyphenation_points("hyphenation", Lang::English, LEFT_MIN, RIGHT_MIN), vec![2, 6]);
}
#[test]
fn hyphenation_of_wonderful_matches_real_hypher_break_points() {
assert_eq!(hyphenation_points("wonderful", Lang::English, LEFT_MIN, RIGHT_MIN), vec![3, 6]);
}
#[test]
fn hyphenation_rejects_words_shorter_than_the_minimum() {
assert!(hyphenation_points("the", Lang::English, LEFT_MIN, RIGHT_MIN).is_empty());
assert!(hyphenation_points("it", Lang::English, LEFT_MIN, RIGHT_MIN).is_empty());
}
#[test]
fn hyphenation_rejects_non_alphabetic_words() {
assert!(hyphenation_points("don't", Lang::English, LEFT_MIN, RIGHT_MIN).is_empty());
assert!(hyphenation_points("well-known", Lang::English, LEFT_MIN, RIGHT_MIN).is_empty());
}
fn assert_points_respect_char_bounds(word: &str, points: &[usize]) {
let byte_len = word.len();
let left_bound_bytes: usize = word.chars().take(LEFT_MIN).map(char::len_utf8).sum();
let right_bound_bytes: usize = word.chars().rev().take(RIGHT_MIN).map(char::len_utf8).sum();
let max_offset = byte_len - right_bound_bytes;
for &p in points {
assert!(
p >= left_bound_bytes && p <= max_offset,
"break point (byte offset {p}) must respect LEFT_MIN/RIGHT_MIN char bounds ({left_bound_bytes}..={max_offset})"
);
}
}
#[test]
fn russian_word_perevodov_yields_a_valid_break_point() {
let word = "переводов";
let points = hyphenation_points(word, Lang::Russian, LEFT_MIN, RIGHT_MIN);
assert!(!points.is_empty(), "a 9-char Russian word must yield at least one break point");
assert_points_respect_char_bounds(word, &points);
}
#[test]
fn russian_word_pokazatelnyi_yields_a_valid_break_point() {
let word = "показательный";
let points = hyphenation_points(word, Lang::Russian, LEFT_MIN, RIGHT_MIN);
assert!(!points.is_empty(), "a long Russian word must yield at least one break point");
assert_points_respect_char_bounds(word, &points);
}
#[test]
fn hyphenation_of_an_unmatched_short_word_is_empty_not_a_guess() {
assert!(hyphenation_points("cat", Lang::English, LEFT_MIN, RIGHT_MIN).is_empty());
}
#[test]
fn wider_left_right_min_suppresses_break_points_a_narrower_bound_allows() {
let narrow = hyphenation_points("hyphenation", Lang::English, 2, 3);
let wide = hyphenation_points("hyphenation", Lang::English, 5, 5);
assert!(!narrow.is_empty(), "the default-ish bound must still find break points on this fixture");
assert!(wide.len() < narrow.len(), "a much wider left_min/right_min must strictly reduce the candidate break points, got narrow={narrow:?} wide={wide:?}");
}
#[test]
fn expand_hyphenation_splits_a_long_word_into_flagged_fragments() {
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let runs = [StyledRun::new("understanding", font)];
let paragraph = Paragraph::new(&runs, f64::MAX);
let shaper = CosmicShaper::headless();
let atoms = build_atom_stream(¶graph, &shaper);
let expanded = expand_hyphenation(atoms, Hyphenation::English, LEFT_MIN, RIGHT_MIN);
let fragments: Vec<&TextAtom> =
expanded.iter().filter_map(|a| if let Atom::Text(t) = a { Some(t) } else { None }).collect();
assert!(fragments.len() > 1, "a long, hyphenatable word must split into more than one fragment");
assert!(!fragments.last().expect("at least one fragment").hyphen_break, "the final fragment must never itself be a hyphen point");
for fragment in fragments.iter().take(fragments.len() - 1) {
assert!(fragment.hyphen_break, "every fragment but the last must be flagged as a hyphen break");
}
for fragment in &fragments {
assert_eq!(fragment.glyphs.first().map(|g| g.x), Some(0.0), "each fragment rebases to x = 0.0");
}
}
#[test]
fn expand_hyphenation_leaves_a_short_or_unmatched_word_as_one_atom() {
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let runs = [StyledRun::new("cat", font)];
let paragraph = Paragraph::new(&runs, f64::MAX);
let shaper = CosmicShaper::headless();
let atoms = build_atom_stream(¶graph, &shaper);
let expanded = expand_hyphenation(atoms, Hyphenation::English, LEFT_MIN, RIGHT_MIN);
let words: Vec<&TextAtom> =
expanded.iter().filter_map(|a| if let Atom::Text(t) = a { if !t.is_glue { Some(t) } else { None } } else { None }).collect();
assert_eq!(words.len(), 1);
assert!(!words[0].hyphen_break);
}
#[test]
fn expand_hyphenation_with_none_returns_atoms_verbatim() {
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let runs = [StyledRun::new("understanding", font)];
let paragraph = Paragraph::new(&runs, f64::MAX);
let shaper = CosmicShaper::headless();
let atoms = build_atom_stream(¶graph, &shaper);
let before_len = atoms.len();
let expanded = expand_hyphenation(atoms, Hyphenation::None, LEFT_MIN, RIGHT_MIN);
assert_eq!(expanded.len(), before_len, "Hyphenation::None must never split any atom");
}
#[test]
fn expand_hyphenation_splits_a_russian_word_end_to_end_via_hyphenation_russian() {
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let runs = [StyledRun::new("показательный", font)];
let paragraph = Paragraph::new(&runs, f64::MAX);
let shaper = CosmicShaper::headless();
let atoms = build_atom_stream(¶graph, &shaper);
let expanded = expand_hyphenation(atoms, Hyphenation::Russian, LEFT_MIN, RIGHT_MIN);
let fragments: Vec<&TextAtom> =
expanded.iter().filter_map(|a| if let Atom::Text(t) = a { Some(t) } else { None }).collect();
assert!(fragments.len() > 1, "a long Russian word must split into more than one fragment under Hyphenation::Russian");
assert!(!fragments.last().expect("at least one fragment").hyphen_break);
}
#[test]
fn hyphenation_named_variants_agree_with_the_generic_lang_variant() {
let via_named = hyphenation_points("wonderful", Lang::English, LEFT_MIN, RIGHT_MIN);
let via_generic = hyphenation_points("wonderful", Lang::English, LEFT_MIN, RIGHT_MIN);
assert_eq!(via_named, via_generic);
assert_eq!(lang_for(Hyphenation::English), Some(Lang::English));
assert_eq!(lang_for(Hyphenation::Russian), Some(Lang::Russian));
assert_eq!(lang_for(Hyphenation::Lang(Lang::German)), Some(Lang::German));
assert_eq!(lang_for(Hyphenation::None), None);
}
}