use uzor::render::GlyphMetric;
use crate::model::{FontSpec, InlineBox, InlineBoxSlot, Paragraph, StyledRun};
use crate::shape::LineShaper;
#[derive(Debug, Clone)]
pub(crate) struct AtomGlyph {
pub cluster: String,
pub x: f64,
pub y_offset: f64,
pub advance: f64,
pub width: f64,
}
#[derive(Debug, Clone)]
pub(crate) struct TextAtom {
pub run_index: usize,
pub glyphs: Vec<AtomGlyph>,
pub width: f64,
pub ascent: f64,
pub descent: f64,
pub shape_font: FontSpec,
pub is_glue: bool,
pub hyphen_break: bool,
}
#[derive(Debug, Clone)]
pub(crate) enum Atom {
Text(TextAtom),
Box(InlineBox),
Break,
}
pub(crate) fn atom_width(atom: &Atom) -> f64 {
match atom {
Atom::Text(t) => t.width,
Atom::Box(b) => b.width(),
Atom::Break => 0.0,
}
}
pub(crate) fn atom_metrics(atom: &Atom) -> (f64, f64) {
match atom {
Atom::Text(t) => (t.ascent, t.descent),
Atom::Box(b) => (b.ascent(), b.descent()),
Atom::Break => (0.0, 0.0),
}
}
pub(crate) fn build_atom_stream(paragraph: &Paragraph<'_>, shaper: &dyn LineShaper) -> Vec<Atom> {
let mut boxes_by_run: Vec<Vec<&InlineBoxSlot>> = vec![Vec::new(); paragraph.runs.len()];
for slot in paragraph.inline_boxes {
if let Some(bucket) = boxes_by_run.get_mut(slot.run_index) {
bucket.push(slot);
}
}
for bucket in &mut boxes_by_run {
bucket.sort_by_key(|slot| slot.byte_offset);
}
let mut atoms = Vec::new();
for (run_index, run) in paragraph.runs.iter().enumerate() {
let text = run.text;
let mut cursor = 0usize;
for slot in &boxes_by_run[run_index] {
let offset = floor_char_boundary(text, slot.byte_offset);
if offset < cursor {
continue;
}
if offset > cursor {
push_text_segment(&mut atoms, run_index, &text[cursor..offset], *run, shaper);
}
atoms.push(Atom::Box(slot.inline_box));
cursor = offset;
}
if cursor < text.len() {
push_text_segment(&mut atoms, run_index, &text[cursor..], *run, shaper);
}
}
atoms
}
fn floor_char_boundary(text: &str, idx: usize) -> usize {
let mut idx = idx.min(text.len());
while idx > 0 && !text.is_char_boundary(idx) {
idx -= 1;
}
idx
}
fn push_text_segment(
atoms: &mut Vec<Atom>,
run_index: usize,
segment: &str,
run: StyledRun<'_>,
shaper: &dyn LineShaper,
) {
if segment.is_empty() {
return;
}
let shape_font = run.vertical_align.shape_font(run.font);
let fallback_height = (shape_font.size_px * 1.2).max(1.0);
let wrapped_lines = shaper.shape_wrapped(segment, &shape_font, f64::MAX);
for (i, line) in wrapped_lines.iter().enumerate() {
if i > 0 {
atoms.push(Atom::Break);
}
let ascent = line.baseline_y.max(0.0);
let descent = (fallback_height - ascent).max(0.0);
push_glyph_atoms(atoms, run_index, &line.glyphs, ascent, descent, shape_font, run.letter_spacing);
}
}
fn push_glyph_atoms(
atoms: &mut Vec<Atom>,
run_index: usize,
glyphs: &[GlyphMetric],
ascent: f64,
descent: f64,
shape_font: FontSpec,
letter_spacing: f64,
) {
let mut i = 0;
while i < glyphs.len() {
let is_ws = is_whitespace_cluster(&glyphs[i].cluster);
let start = i;
while i < glyphs.len() && is_whitespace_cluster(&glyphs[i].cluster) == is_ws {
i += 1;
}
let group = &glyphs[start..i];
let Some(first) = group.first() else {
continue;
};
let base_x = first.x_offset;
let mut extra = 0.0_f64;
let mut atom_glyphs = Vec::with_capacity(group.len());
let mut end_x = 0.0_f64; for g in group {
let x = (g.x_offset - base_x) + extra;
let advance = g.advance + letter_spacing;
atom_glyphs.push(AtomGlyph { cluster: g.cluster.clone(), x, y_offset: g.y_offset, advance, width: g.width });
end_x = x + advance;
extra += letter_spacing;
}
atoms.push(Atom::Text(TextAtom {
run_index,
glyphs: atom_glyphs,
width: end_x,
ascent,
descent,
shape_font,
is_glue: is_ws,
hyphen_break: false,
}));
}
}
fn is_no_break_space(ch: char) -> bool {
matches!(ch, '\u{00A0}' | '\u{202F}')
}
fn is_whitespace_cluster(cluster: &str) -> bool {
!cluster.is_empty() && cluster.chars().all(|c| c.is_whitespace() && !is_no_break_space(c))
}
pub(crate) fn pack_lines(atoms: Vec<Atom>, max_width: f64) -> Vec<Vec<Atom>> {
let max_width = if max_width.is_finite() { max_width.max(1.0) } else { f64::MAX };
let mut lines = Vec::new();
let mut current: Vec<Atom> = Vec::new();
let mut current_width = 0.0_f64;
for atom in atoms {
match atom {
Atom::Break => {
flush_line(&mut current, &mut lines);
current_width = 0.0;
}
Atom::Text(ref t) if t.is_glue => {
if current.is_empty() {
continue; }
current_width += t.width;
current.push(atom);
}
_ => {
let width = atom_width(&atom);
if !current.is_empty() && current_width + width > max_width {
flush_line(&mut current, &mut lines);
current_width = 0.0;
}
current_width += width;
current.push(atom);
}
}
}
if !current.is_empty() {
flush_line(&mut current, &mut lines);
}
lines
}
fn flush_line(current: &mut Vec<Atom>, lines: &mut Vec<Vec<Atom>>) {
while matches!(current.last(), Some(Atom::Text(t)) if t.is_glue) {
current.pop();
}
lines.push(std::mem::take(current));
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::StyledRun;
use crate::shape::CosmicShaper;
use uzor::fonts::FontFamily;
#[test]
fn build_atom_stream_splices_a_box_between_two_text_segments() {
use crate::model::{InlineBox, InlineBoxSlot};
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let runs = [StyledRun::new("before", font), StyledRun::new("after", font)];
let slots = [InlineBoxSlot::new(0, "before".len(), InlineBox::in_flow(1, 10.0, 10.0))];
let paragraph = Paragraph { runs: &runs, inline_boxes: &slots, align: Default::default(), line_height: None, max_width: 1000.0, break_strategy: Default::default(), hyphenation: Default::default(), max_consecutive_hyphens: Default::default(), line_break_params: Default::default(), protrusion: None };
let shaper = CosmicShaper::headless();
let atoms = build_atom_stream(¶graph, &shaper);
assert_eq!(atoms.len(), 3);
assert!(matches!(&atoms[0], Atom::Text(t) if t.run_index == 0 && !t.is_glue));
assert!(matches!(&atoms[1], Atom::Box(b) if b.id == 1));
assert!(matches!(&atoms[2], Atom::Text(t) if t.run_index == 1 && !t.is_glue));
}
#[test]
fn pack_lines_never_starts_a_line_with_leading_whitespace() {
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let runs = [StyledRun::new("one two three", font)];
let paragraph = Paragraph { runs: &runs, inline_boxes: &[], align: Default::default(), line_height: None, max_width: 40.0, break_strategy: Default::default(), hyphenation: Default::default(), max_consecutive_hyphens: Default::default(), line_break_params: Default::default(), protrusion: None };
let shaper = CosmicShaper::headless();
let atoms = build_atom_stream(¶graph, &shaper);
let lines = pack_lines(atoms, 40.0);
assert!(lines.len() > 1);
for line in &lines {
if let Some(Atom::Text(t)) = line.first() {
assert!(!t.is_glue, "line must not start with a glue atom");
}
if let Some(Atom::Text(t)) = line.last() {
assert!(!t.is_glue, "line must not end with a trimmed-away glue atom");
}
}
}
#[test]
fn pack_lines_places_a_single_overflowing_atom_alone_rather_than_panicking() {
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let runs = [StyledRun::new("Supercalifragilisticexpialidocious short", font)];
let paragraph = Paragraph { runs: &runs, inline_boxes: &[], align: Default::default(), line_height: None, max_width: 30.0, break_strategy: Default::default(), hyphenation: Default::default(), max_consecutive_hyphens: Default::default(), line_break_params: Default::default(), protrusion: None };
let shaper = CosmicShaper::headless();
let atoms = build_atom_stream(¶graph, &shaper);
let lines = pack_lines(atoms, 30.0);
assert!(lines.len() >= 2, "the overflow word and \"short\" must land on separate lines");
}
#[test]
fn nbsp_glues_two_words_into_one_non_breaking_atom_never_split_across_lines() {
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let nbsp_runs = [StyledRun::new("hello\u{00A0}world foo bar", font)];
let space_runs = [StyledRun::new("hello world foo bar", font)];
let max_width = 60.0;
let nbsp_paragraph = Paragraph::new( _runs, max_width);
let space_paragraph = Paragraph::new(&space_runs, max_width);
let shaper = CosmicShaper::headless();
let nbsp_atoms = build_atom_stream( _paragraph, &shaper);
let text_atom_count = nbsp_atoms.iter().filter(|a| matches!(a, Atom::Text(t) if !t.is_glue)).count();
assert_eq!(text_atom_count, 3, "hello<NBSP>world must merge into ONE non-glue atom, plus foo/bar = 3 total");
let compound = nbsp_atoms.iter().find_map(|a| match a {
Atom::Text(t) if !t.is_glue => Some(t),
_ => None,
});
assert!(compound.is_some(), "the glued compound must exist as a real Text atom");
let compound_text: String = compound.unwrap().glyphs.iter().map(|g| g.cluster.as_str()).collect();
assert!(compound_text.contains('\u{00A0}'), "the compound atom must still carry the NBSP cluster itself");
let nbsp_lines = pack_lines(nbsp_atoms, max_width);
let space_atoms = build_atom_stream(&space_paragraph, &shaper);
let space_lines = pack_lines(space_atoms, max_width);
assert!(
space_lines.len() > nbsp_lines.len(),
"an ordinary space at this width must wrap MORE than the NBSP-glued fixture \
(NBSP prevents the hello/world split entirely): space={} nbsp={}",
space_lines.len(),
nbsp_lines.len()
);
for line in  _lines {
let has_hello = line.iter().any(|a| matches!(a, Atom::Text(t) if t.glyphs.iter().any(|g| g.cluster == "h")));
let has_world = line.iter().any(|a| matches!(a, Atom::Text(t) if t.glyphs.iter().any(|g| g.cluster == "w")));
assert_eq!(has_hello, has_world, "hello/world must never land on different lines when joined by NBSP");
}
}
#[test]
fn narrow_nbsp_is_also_non_breaking() {
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let runs = [StyledRun::new("12\u{202F}345 rest of the line", font)];
let paragraph = Paragraph::new(&runs, 1000.0);
let shaper = CosmicShaper::headless();
let atoms = build_atom_stream(¶graph, &shaper);
let first_text_atom = atoms.iter().find_map(|a| match a {
Atom::Text(t) if !t.is_glue => Some(t),
_ => None,
});
let text: String = first_text_atom.unwrap().glyphs.iter().map(|g| g.cluster.as_str()).collect();
assert!(text.contains('\u{202F}'), "the first non-glue atom must carry the narrow-NBSP-glued compound \"12 345\", got {text:?}");
}
#[test]
fn justify_still_works_on_a_line_with_an_nbsp_glued_compound() {
use crate::model::ParagraphAlign;
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let text = "one two three\u{00A0}four five six seven eight nine ten";
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 = crate::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,
"non-last line {} must still reach max_width {max_width} under Justify despite the NBSP compound, got {}",
line.line_index,
line.content_width
);
}
}
#[test]
fn letter_spacing_grows_measured_width_linearly() {
let font = FontSpec::new(FontFamily::Roboto, 16.0);
let text = "Spacing";
let base_runs = [StyledRun::new(text, font)];
let base_paragraph = Paragraph::new(&base_runs, f64::MAX);
let shaper = CosmicShaper::headless();
let base_layout = crate::layout::layout_paragraph(&base_paragraph, &shaper);
let spaced_runs = [StyledRun::new(text, font).with_letter_spacing(4.0)];
let spaced_paragraph = Paragraph::new(&spaced_runs, f64::MAX);
let spaced_layout = crate::layout::layout_paragraph(&spaced_paragraph, &shaper);
let glyph_count = base_layout.glyphs.len();
assert_eq!(glyph_count, spaced_layout.glyphs.len(), "letter-spacing must not change the glyph count");
let expected_extra = 4.0 * glyph_count as f64;
assert!(
(spaced_layout.width - base_layout.width - expected_extra).abs() < 0.5,
"spaced width {} must exceed base width {} by ~{expected_extra} (one spacing bump per glyph cluster)",
spaced_layout.width,
base_layout.width
);
}
#[test]
fn superscript_and_subscript_shrink_the_shape_font_and_shift_the_baseline() {
use crate::model::VerticalAlign;
let font = FontSpec::new(FontFamily::Roboto, 20.0);
let base_run = StyledRun::new("x", font);
let sup_run = StyledRun::new("2", font).with_vertical_align(VerticalAlign::Super);
let sub_run = StyledRun::new("n", font).with_vertical_align(VerticalAlign::Sub);
let runs = [base_run, sup_run, sub_run];
let paragraph = Paragraph::new(&runs, 1000.0);
let shaper = CosmicShaper::headless();
let layout = crate::layout::layout_paragraph(¶graph, &shaper);
assert_eq!(layout.lines.len(), 1);
let base_glyph = layout.glyphs.iter().find(|g| g.run_index == 0).expect("base glyph");
let sup_glyph = layout.glyphs.iter().find(|g| g.run_index == 1).expect("superscript glyph");
let sub_glyph = layout.glyphs.iter().find(|g| g.run_index == 2).expect("subscript glyph");
assert!(sup_glyph.font.size_px < base_glyph.font.size_px, "superscript must shape at a smaller font size");
assert!(sub_glyph.font.size_px < base_glyph.font.size_px, "subscript must shape at a smaller font size");
assert!(sup_glyph.y < base_glyph.y, "superscript must paint ABOVE the baseline run (smaller y)");
assert!(sub_glyph.y > base_glyph.y, "subscript must paint BELOW the baseline run (larger y)");
}
}