use std::collections::{HashMap, VecDeque};
use std::rc::Rc;
use crate::fonts::{measure_text, shape_text, Font, FontCollection, ShapedGlyph};
use crate::html::NodeId;
use crate::style::{
BoxSizing, ComputedStyle, ComputedTextShadow, Display, EmphasisPosition, EmphasisStyle,
FontStyle, FontWeight, Hyphens, LengthPercentage, LengthPercentageOrAuto, LineHeight,
OverflowWrap, RgbaColor, TextAlign, TextOverflow, TextTransform, VerticalAlign, WhiteSpace,
WordBreak,
};
use super::block::{resolve_relative_offset, LaidOutBox, LaidOutContent, PosCtx};
use super::box_tree::{BoxContent, InlineSpan, LayoutBox};
use super::float_ctx::FloatContext;
use super::geometry::Rect;
use super::white_space;
const ATOMIC_PLACEHOLDER: char = '\u{FFFC}';
#[derive(Debug, Clone, PartialEq)]
pub struct EmphasisMark {
pub style: EmphasisStyle,
pub color: RgbaColor,
pub position: EmphasisPosition,
pub size: f32,
}
const EMPHASIS_SIZE_RATIO: f32 = 0.5;
const SOFT_HYPHEN: char = '\u{00AD}';
const HYPHEN: &str = "-";
const ELLIPSIS: &str = "…";
const LINE_FIT_EPSILON: f32 = 0.01;
#[derive(Debug, Clone, PartialEq)]
pub struct TextRun {
pub font_index: usize,
pub font_size: f32,
pub color: RgbaColor,
pub link: Option<Rc<str>>,
pub background_color: RgbaColor,
pub bold: bool,
pub italic: bool,
pub underline: bool,
pub line_through: bool,
pub text: String,
pub glyphs: Vec<ShapedGlyph>,
pub x_offset: f32,
pub width: f32,
pub line_height: f32,
pub letter_spacing: f32,
pub word_spacing: f32,
pub ascent: f32,
pub descent: f32,
pub baseline_shift: f32,
pub text_shadow: Option<Rc<[ComputedTextShadow]>>,
pub emphasis: Option<EmphasisMark>,
pub(super) style_index: usize,
pub(super) hyphen_before: bool,
pub(super) break_before: bool,
pub(super) vertical_align: VerticalAlign,
}
#[derive(Debug, Clone)]
pub struct LineBox {
pub rect: Rect,
pub runs: Vec<TextRun>,
pub baseline: f32,
pub atomics: Vec<AtomicInline>,
}
#[derive(Debug, Clone)]
pub struct AtomicInline {
pub content: LaidOutBox,
pub x_offset: f32,
pub margin_box_width: f32,
pub margin_box_height: f32,
pub baseline_from_bottom: f32,
pub baseline_shift: f32,
pub(super) vertical_align: VerticalAlign,
pub link: Option<Rc<str>>,
}
#[derive(Debug, Clone, Copy)]
struct StyledChar {
ch: char,
style_index: usize,
atomic_span: Option<usize>,
is_forced_break: bool,
hyphen_before: bool,
break_before: bool,
}
enum InlineItem<'a> {
Word {
chars: &'a [StyledChar],
space_before: bool,
},
Atomic {
span_index: usize,
space_before: bool,
},
ForcedBreak { style_index: usize },
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn layout_inline_content(
spans: &[InlineSpan],
styles: &HashMap<NodeId, Rc<ComputedStyle>>,
fonts: &FontCollection,
available_width: f32,
origin_x: f32,
origin_y: f32,
float_ctx: Option<&FloatContext>,
container_style: Option<&ComputedStyle>,
pos: &mut PosCtx,
) -> Vec<LineBox> {
let mut lines = layout_inline_content_in_flow(
spans,
styles,
fonts,
available_width,
origin_x,
origin_y,
float_ctx,
container_style,
pos,
);
apply_relative_span_offsets(&mut lines, spans, available_width);
lines
}
fn apply_relative_span_offsets(lines: &mut [LineBox], spans: &[InlineSpan], available_width: f32) {
if spans.iter().all(|span| span.relative_insets.is_empty()) {
return;
}
let offsets: Vec<(f32, f32)> = spans
.iter()
.map(|span| {
span.relative_insets
.iter()
.fold((0.0, 0.0), |(dx, dy), inset| {
let (x, y) = resolve_relative_offset(inset, available_width);
(dx + x, dy + y)
})
})
.collect();
for run in lines.iter_mut().flat_map(|line| line.runs.iter_mut()) {
let Some(&(dx, dy)) = offsets.get(run.style_index) else {
continue;
};
run.x_offset += dx;
run.baseline_shift -= dy;
}
}
#[allow(clippy::too_many_arguments)]
fn layout_inline_content_in_flow(
spans: &[InlineSpan],
styles: &HashMap<NodeId, Rc<ComputedStyle>>,
fonts: &FontCollection,
available_width: f32,
origin_x: f32,
origin_y: f32,
float_ctx: Option<&FloatContext>,
container_style: Option<&ComputedStyle>,
pos: &mut PosCtx,
) -> Vec<LineBox> {
if fonts.is_empty() || spans.is_empty() {
return Vec::new();
}
let (chars, span_styles, span_links) = flatten_spans(spans, styles);
let representative = spans
.iter()
.position(|span| span.atomic.is_none())
.and_then(|i| span_styles.get(i));
let white_space = representative.map(|s| s.white_space).unwrap_or_default();
let text_align = container_style
.map(|s| s.text_align)
.or_else(|| representative.map(|s| s.text_align))
.unwrap_or_default();
let word_break = representative.map(|s| s.word_break).unwrap_or_default();
let overflow_wrap = representative.map(|s| s.overflow_wrap).unwrap_or_default();
let text_indent = representative
.map(|s| resolve_length_percentage(s.text_indent, available_width))
.unwrap_or(0.0);
if white_space == WhiteSpace::Pre {
let chars: Vec<StyledChar> = chars
.into_iter()
.filter(|sc| sc.atomic_span.is_none())
.collect();
return layout_pre_content(
&chars,
&span_styles,
&span_links,
fonts,
available_width,
origin_x,
origin_y,
float_ctx,
);
}
let items = split_into_items(&chars);
if items.is_empty() {
return Vec::new();
}
let mut lines = Vec::new();
let mut current_runs: Vec<TextRun> = Vec::new();
let mut current_atomics: Vec<AtomicInline> = Vec::new();
let mut current_width = 0.0f32;
let mut cursor_y = origin_y;
let mut line_left = origin_x;
let mut line_available_width = available_width;
let mut word_boundaries: Vec<f32> = Vec::new();
let mut cleared_by_trailing_break = false;
for item in items {
let (word, word_space_before) = match item {
InlineItem::Word {
chars,
space_before,
} => {
cleared_by_trailing_break = false;
(chars, space_before)
}
InlineItem::Atomic {
span_index,
space_before,
} => {
cleared_by_trailing_break = false;
let Some(span) = spans.get(span_index) else {
continue;
};
let Some(atomic) = span.atomic.as_deref() else {
continue;
};
let style = span_styles.get(span_index).cloned().unwrap_or_default();
if current_runs.is_empty() && current_atomics.is_empty() {
(line_left, line_available_width) =
line_band(float_ctx, cursor_y, 0.0, origin_x, available_width);
if lines.is_empty() {
line_left += text_indent;
line_available_width -= text_indent;
}
}
let laid = layout_atomic_inline(atomic, styles, fonts, line_available_width, pos);
let margin_box_width = margin_box_width_of(&laid);
let margin_box_height = laid.layout.margin_box_height();
let baseline_from_bottom = if style.display == Display::InlineFlex {
first_baseline_from_bottom(&laid).unwrap_or(0.0)
} else {
0.0
};
let gap_width = if space_before {
current_runs
.last()
.map(|last| {
measure_space_width(fonts, last.font_index, last.font_size)
+ last.word_spacing
})
.unwrap_or(0.0)
} else {
0.0
};
let line_is_empty = current_runs.is_empty() && current_atomics.is_empty();
if !line_is_empty
&& white_space != WhiteSpace::Nowrap
&& current_width + gap_width + margin_box_width > line_available_width
{
let line_height = line_height_for(¤t_runs);
lines.push(finish_line(
std::mem::take(&mut current_runs),
std::mem::take(&mut current_atomics),
current_width,
line_left,
cursor_y,
line_height,
fonts,
));
apply_text_align(
lines.last_mut().expect("just pushed"),
text_align,
false,
line_available_width,
&word_boundaries,
);
word_boundaries.clear();
cursor_y += lines.last().expect("just pushed").rect.height;
current_width = 0.0;
(line_left, line_available_width) = line_band(
float_ctx,
cursor_y,
margin_box_height,
origin_x,
available_width,
);
} else if !line_is_empty {
if space_before {
word_boundaries.push(current_width);
}
current_width += gap_width;
}
current_atomics.push(AtomicInline {
content: laid,
x_offset: current_width,
margin_box_width,
margin_box_height,
baseline_from_bottom,
baseline_shift: 0.0,
vertical_align: style.vertical_align,
link: span.link.clone(),
});
current_width += margin_box_width;
continue;
}
InlineItem::ForcedBreak { style_index } => {
let break_height = span_styles
.get(style_index)
.map(|style| empty_line_height(style, fonts))
.unwrap_or(0.0);
if current_runs.is_empty() && current_atomics.is_empty() {
(line_left, line_available_width) =
line_band(float_ctx, cursor_y, break_height, origin_x, available_width);
lines.push(finish_line(
Vec::new(),
Vec::new(),
0.0,
line_left,
cursor_y,
break_height,
fonts,
));
cursor_y += break_height;
} else {
let line_height = line_height_for(¤t_runs);
lines.push(finish_line(
std::mem::take(&mut current_runs),
std::mem::take(&mut current_atomics),
current_width,
line_left,
cursor_y,
line_height,
fonts,
));
apply_text_align(
lines.last_mut().expect("just pushed"),
text_align,
true,
line_available_width,
&word_boundaries,
);
word_boundaries.clear();
cursor_y += lines.last().expect("just pushed").rect.height;
current_width = 0.0;
}
if let (Some(ctx), Some(clear)) =
(float_ctx, span_styles.get(style_index).map(|s| s.clear))
{
let cleared_y = ctx.clearance(clear, cursor_y);
cleared_by_trailing_break = cleared_y > cursor_y;
cursor_y = cleared_y;
}
continue;
}
};
let word_runs = split_word_into_runs(word, &span_styles, &span_links, fonts, word_break);
let mut chunk_queue: VecDeque<(Vec<TextRun>, bool, bool)> =
group_into_chunks(word_runs, word_break)
.into_iter()
.enumerate()
.map(|(chunk_index, chunk)| (chunk, chunk_index == 0, true))
.collect();
while let Some((chunk, is_first_chunk_of_word, allow_break_fallback)) =
chunk_queue.pop_front()
{
let chunk_width: f32 = chunk.iter().map(|r| r.width).sum();
let starting_new_line = current_runs.is_empty() && current_atomics.is_empty();
if starting_new_line {
let hint = line_height_hint_for_chunk(&chunk);
(line_left, line_available_width) =
line_band(float_ctx, cursor_y, hint, origin_x, available_width);
if lines.is_empty() {
line_left += text_indent;
line_available_width -= text_indent;
}
}
let gap_width = if is_first_chunk_of_word && word_space_before {
current_runs
.last()
.map(|last| {
measure_space_width(fonts, last.font_index, last.font_size)
+ last.word_spacing
})
.unwrap_or(0.0)
} else {
0.0
};
if starting_new_line
&& allow_break_fallback
&& overflow_wrap == OverflowWrap::BreakWord
&& white_space != WhiteSpace::Nowrap
&& chunk_width > line_available_width + LINE_FIT_EPSILON
{
let (head, rest) = split_chunk_to_fit(chunk, line_available_width);
if !head.is_empty() && !rest.is_empty() {
chunk_queue.push_front((rest, false, true));
chunk_queue.push_front((head, is_first_chunk_of_word, false));
} else {
let restored: Vec<TextRun> = head.into_iter().chain(rest).collect();
chunk_queue.push_front((restored, is_first_chunk_of_word, false));
}
continue;
}
if !starting_new_line
&& white_space != WhiteSpace::Nowrap
&& current_width + gap_width + chunk_width > line_available_width + LINE_FIT_EPSILON
{
if chunk.first().is_some_and(|run| run.hyphen_before) {
push_hyphen(&mut current_runs, &mut current_width, &span_styles, fonts);
}
let line_height = line_height_for(¤t_runs);
lines.push(finish_line(
std::mem::take(&mut current_runs),
std::mem::take(&mut current_atomics),
current_width,
line_left,
cursor_y,
line_height,
fonts,
));
apply_text_align(
lines.last_mut().expect("just pushed"),
text_align,
false,
line_available_width,
&word_boundaries,
);
word_boundaries.clear();
cursor_y += lines.last().expect("just pushed").rect.height;
current_width = 0.0;
let hint = line_height_hint_for_chunk(&chunk);
(line_left, line_available_width) =
line_band(float_ctx, cursor_y, hint, origin_x, available_width);
chunk_queue.push_front((chunk, is_first_chunk_of_word, allow_break_fallback));
continue;
} else if !starting_new_line {
if is_first_chunk_of_word && word_space_before {
word_boundaries.push(current_width);
}
current_width += gap_width;
}
for mut run in chunk {
run.x_offset = current_width;
current_width += run.width;
current_runs.push(run);
}
}
}
if !current_runs.is_empty() || !current_atomics.is_empty() {
let line_height = line_height_for(¤t_runs);
lines.push(finish_line(
current_runs,
current_atomics,
current_width,
line_left,
cursor_y,
line_height,
fonts,
));
apply_text_align(
lines.last_mut().expect("just pushed"),
text_align,
true,
line_available_width,
&word_boundaries,
);
} else if cleared_by_trailing_break {
lines.push(finish_line(
Vec::new(),
Vec::new(),
0.0,
origin_x,
cursor_y,
0.0,
fonts,
));
}
for line in &mut lines {
merge_adjacent_runs(line, fonts);
line.runs.shrink_to_fit();
line.atomics.shrink_to_fit();
}
lines.shrink_to_fit();
lines
}
fn merge_adjacent_runs(line: &mut LineBox, fonts: &FontCollection) {
if line.runs.len() < 2 {
return;
}
let mut merged: Vec<TextRun> = Vec::with_capacity(line.runs.len());
for run in std::mem::take(&mut line.runs) {
let Some(prev) = merged.last_mut() else {
merged.push(run);
continue;
};
match gap_if_mergeable(prev, &run, fonts) {
Some(gap) => append_run(prev, run, gap, fonts),
None => merged.push(run),
}
}
line.runs = merged;
}
fn gap_if_mergeable(prev: &TextRun, next: &TextRun, fonts: &FontCollection) -> Option<f32> {
let same_style = prev.font_index == next.font_index
&& prev.font_size == next.font_size
&& prev.color == next.color
&& prev.background_color == next.background_color
&& prev.bold == next.bold
&& prev.italic == next.italic
&& prev.underline == next.underline
&& prev.line_through == next.line_through
&& prev.line_height == next.line_height
&& prev.word_spacing == next.word_spacing
&& prev.ascent == next.ascent
&& prev.descent == next.descent
&& prev.baseline_shift == next.baseline_shift
&& prev.vertical_align == next.vertical_align
&& prev.style_index == next.style_index
&& prev.link == next.link
&& prev.text_shadow == next.text_shadow;
if !same_style {
return None;
}
if prev.letter_spacing != 0.0 || next.letter_spacing != 0.0 {
return None;
}
if prev.emphasis.is_some() || next.emphasis.is_some() {
return None;
}
if next.hyphen_before {
return None;
}
let gap = next.x_offset - (prev.x_offset + prev.width);
if gap < -0.01 {
return None;
}
let gap = gap.max(0.0);
if gap > 0.01 && space_glyph(prev.font_index, fonts).is_none() {
return None;
}
Some(gap)
}
fn space_glyph(font_index: usize, fonts: &FontCollection) -> Option<u16> {
fonts.get(font_index).and_then(|font| font.glyph_id(' '))
}
fn append_run(prev: &mut TextRun, next: TextRun, gap: f32, fonts: &FontCollection) {
if gap > 0.01 {
let glyph_id =
space_glyph(prev.font_index, fonts).expect("its presence was just confirmed");
prev.glyphs.push(ShapedGlyph {
glyph_id,
cluster: prev.text.len() as u32,
x_advance: gap,
x_offset: 0.0,
y_offset: 0.0,
});
prev.text.push(' ');
}
let base = prev.text.len() as u32;
prev.glyphs.extend(next.glyphs.into_iter().map(|mut glyph| {
glyph.cluster += base;
glyph
}));
prev.text.push_str(&next.text);
prev.width = next.x_offset + next.width - prev.x_offset;
}
fn apply_text_align(
line: &mut LineBox,
text_align: TextAlign,
is_last_line: bool,
line_available_width: f32,
word_boundaries: &[f32],
) {
let leftover = line_available_width - line.rect.width;
match text_align {
TextAlign::Left => {}
TextAlign::Right => shift_all_runs(line, leftover),
TextAlign::Center => shift_all_runs(line, leftover / 2.0),
TextAlign::Justify if !is_last_line && !word_boundaries.is_empty() && leftover > 0.0 => {
let extra = leftover / word_boundaries.len() as f32;
let shift_at =
|x: f32| extra * word_boundaries.iter().filter(|&&b| b <= x).count() as f32;
for run in &mut line.runs {
run.x_offset += shift_at(run.x_offset);
}
for atomic in &mut line.atomics {
atomic.x_offset += shift_at(atomic.x_offset);
}
line.rect.width = line_available_width;
}
TextAlign::Justify => {}
}
}
fn shift_all_runs(line: &mut LineBox, shift: f32) {
if shift <= 0.0 {
return;
}
for run in &mut line.runs {
run.x_offset += shift;
}
for atomic in &mut line.atomics {
atomic.x_offset += shift;
}
}
fn line_height_hint_for_chunk(chunk: &[TextRun]) -> f32 {
chunk.first().map(|r| r.line_height).unwrap_or(0.0)
}
fn line_band(
float_ctx: Option<&FloatContext>,
y: f32,
height: f32,
origin_x: f32,
available_width: f32,
) -> (f32, f32) {
match float_ctx {
Some(ctx) => ctx.available_band(y, height, origin_x, origin_x + available_width),
None => (origin_x, available_width),
}
}
fn flatten_spans(
spans: &[InlineSpan],
styles: &HashMap<NodeId, Rc<ComputedStyle>>,
) -> (Vec<StyledChar>, Vec<ComputedStyle>, Vec<Option<Rc<str>>>) {
let mut chars = Vec::new();
let mut span_styles = Vec::with_capacity(spans.len());
let mut span_links: Vec<Option<Rc<str>>> = Vec::with_capacity(spans.len());
let mut prev_is_boundary = true;
let mut hyphen_pending = false;
let mut break_pending = false;
for span in spans {
let mut style = styles
.get(&span.node)
.map(|shared| (**shared).clone())
.unwrap_or_default();
style.background_color = span.background_color;
if span.outside_inline_element {
style.vertical_align = VerticalAlign::Baseline;
}
if span.is_first_letter {
apply_first_letter_style(&mut style);
}
let style_index = span_styles.len();
let transform = style.text_transform;
span_styles.push(style);
span_links.push(span.link.clone());
if span.atomic.is_some() {
chars.push(StyledChar {
ch: ATOMIC_PLACEHOLDER,
style_index,
atomic_span: Some(style_index),
is_forced_break: false,
hyphen_before: hyphen_pending,
break_before: break_pending,
});
hyphen_pending = false;
break_pending = false;
prev_is_boundary = false;
continue;
}
let hyphens = span_styles[style_index].hyphens;
for ch in span.text.chars() {
if ch == SOFT_HYPHEN {
hyphen_pending = hyphens == Hyphens::Manual;
break_pending |= hyphen_pending;
continue;
}
if ch == white_space::ZERO_WIDTH_SPACE {
break_pending = true;
continue;
}
let is_word_start = prev_is_boundary;
let transformed = apply_text_transform(ch, transform, is_word_start);
chars.push(StyledChar {
ch: transformed,
style_index,
atomic_span: None,
is_forced_break: span.is_forced_break,
hyphen_before: hyphen_pending,
break_before: break_pending,
});
hyphen_pending = false;
break_pending = false;
prev_is_boundary = ch.is_whitespace();
}
}
(chars, span_styles, span_links)
}
fn apply_first_letter_style(style: &mut ComputedStyle) {
let Some(first_letter) = style.first_letter_style.clone() else {
return;
};
if let Some(v) = first_letter.font_size {
style.font_size = v;
}
if let Some(v) = first_letter.font_family {
style.font_family = v;
}
if let Some(v) = first_letter.font_weight {
style.font_weight = v;
}
if let Some(v) = first_letter.font_style {
style.font_style = v;
}
if let Some(v) = first_letter.color {
style.color = v;
}
if let Some(v) = first_letter.text_decoration_line {
style.text_decoration_line = v;
}
if let Some(v) = first_letter.text_transform {
style.text_transform = v;
}
}
fn apply_text_transform(ch: char, transform: TextTransform, is_word_start: bool) -> char {
match transform {
TextTransform::None => ch,
TextTransform::Uppercase => ch.to_uppercase().next().unwrap_or(ch),
TextTransform::Lowercase => ch.to_lowercase().next().unwrap_or(ch),
TextTransform::Capitalize if is_word_start && !ch.is_whitespace() => {
ch.to_uppercase().next().unwrap_or(ch)
}
TextTransform::Capitalize => ch,
}
}
fn split_into_items(chars: &[StyledChar]) -> Vec<InlineItem<'_>> {
let mut items = Vec::new();
let mut word_start = 0usize;
let mut space_pending = false;
for (i, sc) in chars.iter().enumerate() {
if let Some(span_index) = sc.atomic_span {
if word_start < i {
items.push(InlineItem::Word {
chars: &chars[word_start..i],
space_before: space_pending,
});
space_pending = false;
}
items.push(InlineItem::Atomic {
span_index,
space_before: space_pending,
});
space_pending = false;
word_start = i + 1;
continue;
}
if !white_space::is_collapsible(sc.ch) {
continue;
}
if word_start < i {
items.push(InlineItem::Word {
chars: &chars[word_start..i],
space_before: space_pending,
});
}
space_pending = true;
if sc.is_forced_break {
items.push(InlineItem::ForcedBreak {
style_index: sc.style_index,
});
space_pending = false;
}
word_start = i + 1;
}
if word_start < chars.len() {
items.push(InlineItem::Word {
chars: &chars[word_start..],
space_before: space_pending,
});
}
items
}
fn split_word_into_runs(
word: &[StyledChar],
span_styles: &[ComputedStyle],
span_links: &[Option<Rc<str>>],
fonts: &FontCollection,
word_break: WordBreak,
) -> Vec<TextRun> {
let mut runs = Vec::new();
let mut current: Option<(usize, usize)> = None;
let mut current_text = String::new();
let mut last_char: Option<char> = None;
let mut current_hyphen_before = false;
let mut current_break_before = false;
let flush = |runs: &mut Vec<TextRun>,
current: Option<(usize, usize)>,
text: &str,
hyphen_before: bool,
break_before: bool| {
if let Some((style_index, fi)) = current {
let mut run = shape_run(text, fi, fonts, &span_styles[style_index]);
run.link = span_links.get(style_index).cloned().flatten();
run.style_index = style_index;
run.hyphen_before = hyphen_before;
run.break_before = break_before;
runs.push(run);
}
};
for sc in word {
let style = &span_styles[sc.style_index];
let font_index = fonts
.select_for_char(
&style.font_family,
style.font_weight,
style.font_style,
sc.ch,
)
.unwrap_or(0);
let continues_current = match (current, last_char) {
(Some((style_index, fi)), Some(prev_ch)) => {
style_index == sc.style_index
&& fi == font_index
&& !sc.break_before
&& !is_break_boundary(prev_ch, sc.ch, word_break)
}
_ => false,
};
if continues_current {
current_text.push(sc.ch);
} else {
flush(
&mut runs,
current,
¤t_text,
current_hyphen_before,
current_break_before,
);
current_text = sc.ch.to_string();
current = Some((sc.style_index, font_index));
current_hyphen_before = sc.hyphen_before;
current_break_before = sc.break_before;
}
last_char = Some(sc.ch);
}
flush(
&mut runs,
current,
¤t_text,
current_hyphen_before,
current_break_before,
);
runs
}
fn group_into_chunks(runs: Vec<TextRun>, word_break: WordBreak) -> Vec<Vec<TextRun>> {
let mut chunks: Vec<Vec<TextRun>> = Vec::new();
for run in runs {
let starts_new_chunk = match chunks.last().and_then(|chunk| chunk.last()) {
None => true,
Some(_) if run.break_before => true,
Some(prev) => is_break_boundary(
prev.text.chars().last().unwrap_or(' '),
run.text.chars().next().unwrap_or(' '),
word_break,
),
};
if starts_new_chunk {
chunks.push(vec![run]);
} else {
chunks.last_mut().expect("just checked non-empty").push(run);
}
}
chunks
}
pub(super) fn apply_text_overflow(
lines: &mut [LineBox],
style: &ComputedStyle,
content_width: f32,
fonts: &FontCollection,
) {
if !style.overflow.clips() || style.text_overflow != TextOverflow::Ellipsis {
return;
}
for line in lines.iter_mut() {
let line_width = line
.runs
.last()
.map(|run| run.x_offset + run.width)
.unwrap_or(0.0);
if line_width <= content_width {
continue;
}
let Some(last) = line.runs.last() else {
continue;
};
let Some(mut ellipsis) = shape_ellipsis(last.font_index, last.style_index, style, fonts)
else {
continue;
};
let budget = (content_width - ellipsis.width).max(0.0);
let mut kept: Vec<TextRun> = Vec::with_capacity(line.runs.len());
let mut end_x = 0.0f32;
for run in std::mem::take(&mut line.runs) {
if run.x_offset + run.width <= budget {
end_x = run.x_offset + run.width;
kept.push(run);
continue;
}
if let (Some(fitting), _) = split_run_at_width(&run, budget - run.x_offset) {
end_x = run.x_offset + fitting.width;
kept.push(fitting);
}
break;
}
ellipsis.x_offset = end_x;
kept.push(ellipsis);
line.runs = kept;
}
}
fn shape_ellipsis(
font_index: usize,
style_index: usize,
style: &ComputedStyle,
fonts: &FontCollection,
) -> Option<TextRun> {
for text in [ELLIPSIS, HYPHEN] {
let mut run = shape_run(text, font_index, fonts, style);
if run.glyphs.is_empty() || run.glyphs.iter().any(|g| g.glyph_id == 0) {
continue;
}
run.style_index = style_index;
return Some(run);
}
None
}
fn push_hyphen(
current_runs: &mut Vec<TextRun>,
current_width: &mut f32,
span_styles: &[ComputedStyle],
fonts: &FontCollection,
) {
let Some(last) = current_runs.last() else {
return;
};
let (font_index, style_index) = (last.font_index, last.style_index);
let Some(style) = span_styles.get(style_index) else {
return;
};
let mut hyphen = shape_run(HYPHEN, font_index, fonts, style);
if hyphen.glyphs.is_empty() {
return;
}
hyphen.style_index = style_index;
hyphen.x_offset = *current_width;
*current_width += hyphen.width;
current_runs.push(hyphen);
}
fn split_chunk_to_fit(chunk: Vec<TextRun>, max_width: f32) -> (Vec<TextRun>, Vec<TextRun>) {
let mut head = Vec::new();
let mut rest = Vec::new();
let mut used = 0.0f32;
for run in chunk {
if !rest.is_empty() {
rest.push(run);
continue;
}
if used + run.width <= max_width {
used += run.width;
head.push(run);
continue;
}
let (fitting, remainder) = split_run_at_width(&run, max_width - used);
if let Some(fitting) = fitting {
used += fitting.width;
head.push(fitting);
}
if let Some(remainder) = remainder {
rest.push(remainder);
}
}
(head, rest)
}
fn split_run_at_width(run: &TextRun, max_width: f32) -> (Option<TextRun>, Option<TextRun>) {
let mut used = 0.0f32;
let mut glyph_count = 0usize;
for glyph in &run.glyphs {
let advance = glyph.x_advance + run.letter_spacing;
if used + advance > max_width {
break;
}
used += advance;
glyph_count += 1;
}
if glyph_count == 0 {
return (None, Some(run.clone()));
}
if glyph_count == run.glyphs.len() {
return (Some(run.clone()), None);
}
let split_byte = (run.glyphs[glyph_count].cluster as usize).min(run.text.len());
let mut head = run.clone();
head.glyphs = run.glyphs[..glyph_count].to_vec();
head.text = run.text[..split_byte].to_string();
head.width = used;
let mut tail = run.clone();
tail.glyphs = run.glyphs[glyph_count..]
.iter()
.map(|glyph| ShapedGlyph {
cluster: glyph.cluster.saturating_sub(split_byte as u32),
..*glyph
})
.collect();
tail.text = run.text[split_byte..].to_string();
tail.width = (run.width - used).max(0.0);
tail.x_offset = 0.0;
tail.hyphen_before = false;
tail.break_before = false;
(Some(head), Some(tail))
}
fn is_break_boundary(prev: char, next: char, word_break: WordBreak) -> bool {
if white_space::is_non_breaking(prev) || white_space::is_non_breaking(next) {
return false;
}
if white_space::allows_break_after(prev) {
return true;
}
match word_break {
WordBreak::BreakAll => true,
WordBreak::KeepAll => false,
WordBreak::Normal => is_cjk(prev) || is_cjk(next),
}
}
fn is_cjk(ch: char) -> bool {
matches!(ch as u32,
0x3000..=0x303F | 0x3040..=0x30FF | 0x31F0..=0x31FF | 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xAC00..=0xD7A3 | 0xF900..=0xFAFF | 0xFF00..=0xFFEF )
}
fn resolve_length_percentage(lp: LengthPercentage, basis: f32) -> f32 {
match lp {
LengthPercentage::Length(px) => px,
LengthPercentage::Percentage(fraction) => fraction * basis,
LengthPercentage::Calc { px, percent } => px + percent * basis,
}
}
const NORMAL_LINE_HEIGHT_FALLBACK: f32 = 1.2;
fn resolve_line_height(style: &ComputedStyle, font: Option<&Font>) -> f32 {
let font_size = style.font_size.0;
match style.line_height {
LineHeight::Normal => match font {
Some(font) => font.normal_line_height(font_size),
None => font_size * NORMAL_LINE_HEIGHT_FALLBACK,
},
LineHeight::Number(n) => n * font_size,
LineHeight::Length(px) => px,
}
}
fn first_available_font<'a>(style: &ComputedStyle, fonts: &'a FontCollection) -> Option<&'a Font> {
fonts
.select_for_char(&style.font_family, style.font_weight, style.font_style, ' ')
.and_then(|index| fonts.get(index))
}
fn empty_line_height(style: &ComputedStyle, fonts: &FontCollection) -> f32 {
resolve_line_height(style, first_available_font(style, fonts))
}
#[allow(clippy::too_many_arguments)]
fn layout_pre_content(
chars: &[StyledChar],
span_styles: &[ComputedStyle],
span_links: &[Option<Rc<str>>],
fonts: &FontCollection,
available_width: f32,
origin_x: f32,
origin_y: f32,
float_ctx: Option<&FloatContext>,
) -> Vec<LineBox> {
let text_indent = span_styles
.first()
.map(|s| resolve_length_percentage(s.text_indent, available_width))
.unwrap_or(0.0);
let mut lines = Vec::new();
let mut cursor_y = origin_y;
let mut segments: Vec<&[StyledChar]> = chars.split(|sc| sc.ch == '\n').collect();
if segments.len() > 1 && segments.last().is_some_and(|segment| segment.is_empty()) {
segments.pop();
}
for segment in segments {
let hint = segment
.first()
.and_then(|sc| span_styles.get(sc.style_index))
.or_else(|| span_styles.first())
.map(|style| empty_line_height(style, fonts))
.unwrap_or(0.0);
let (mut line_left, _) = line_band(float_ctx, cursor_y, hint, origin_x, available_width);
if lines.is_empty() {
line_left += text_indent;
}
if segment.is_empty() {
lines.push(finish_line(
Vec::new(),
Vec::new(),
0.0,
line_left,
cursor_y,
hint,
fonts,
));
cursor_y += hint;
continue;
}
let runs = split_word_into_runs(segment, span_styles, span_links, fonts, WordBreak::Normal);
let mut current_width = 0.0;
let mut placed_runs = Vec::with_capacity(runs.len());
for mut run in runs {
run.x_offset = current_width;
current_width += run.width;
placed_runs.push(run);
}
let line_height = line_height_for(&placed_runs);
lines.push(finish_line(
placed_runs,
Vec::new(),
current_width,
line_left,
cursor_y,
line_height,
fonts,
));
cursor_y += line_height;
}
lines
}
pub(super) fn shape_run(
text: &str,
font_index: usize,
fonts: &FontCollection,
style: &ComputedStyle,
) -> TextRun {
let font = fonts
.get(font_index)
.expect("font_index is always in range");
let font_size = style.font_size.0;
let shaped = shape_text(font, text, font_size);
let needs_synthetic_bold = style.font_weight == FontWeight::Bold && !fonts.is_bold(font_index);
let needs_synthetic_italic =
style.font_style == FontStyle::Italic && !fonts.is_italic(font_index);
let mut line_height = resolve_line_height(style, Some(font));
let width = shaped.width + style.letter_spacing * shaped.glyphs.len() as f32;
let units_per_em = font.units_per_em() as f32;
let mut ascent = font.ascender() as f32 / units_per_em * font_size;
let mut descent = -(font.descender() as f32) / units_per_em * font_size;
let emphasis = (style.text_emphasis_style != EmphasisStyle::None).then(|| {
let size = font_size * EMPHASIS_SIZE_RATIO;
match style.text_emphasis_position {
EmphasisPosition::Over => ascent += size,
EmphasisPosition::Under => descent += size,
}
line_height += size;
EmphasisMark {
style: style.text_emphasis_style.clone(),
color: style.text_emphasis_color,
position: style.text_emphasis_position,
size,
}
});
TextRun {
font_index,
font_size,
color: style.color,
link: None,
background_color: style.background_color,
bold: needs_synthetic_bold,
italic: needs_synthetic_italic,
underline: style.text_decoration_line.underline,
line_through: style.text_decoration_line.line_through,
text: text.to_string(),
glyphs: shaped.glyphs,
x_offset: 0.0,
width,
line_height,
letter_spacing: style.letter_spacing,
word_spacing: style.word_spacing,
ascent,
descent,
baseline_shift: 0.0,
vertical_align: style.vertical_align,
text_shadow: (!style.text_shadow.is_empty())
.then(|| Rc::from(style.text_shadow.as_slice())),
emphasis,
style_index: 0,
hyphen_before: false,
break_before: false,
}
}
pub fn shape_standalone_line(
text: &str,
style: &ComputedStyle,
fonts: &FontCollection,
origin_x: f32,
origin_y: f32,
) -> LineBox {
let mut runs: Vec<TextRun> = Vec::new();
let mut current_font: Option<usize> = None;
let mut current_text = String::new();
for ch in text.chars() {
let font_index = fonts
.select_for_char(&style.font_family, style.font_weight, style.font_style, ch)
.unwrap_or(0);
if current_font == Some(font_index) {
current_text.push(ch);
} else {
if let Some(fi) = current_font {
runs.push(shape_run(¤t_text, fi, fonts, style));
}
current_text.clear();
current_text.push(ch);
current_font = Some(font_index);
}
}
if let Some(fi) = current_font {
runs.push(shape_run(¤t_text, fi, fonts, style));
}
let mut x_cursor = 0.0;
let mut max_height: f32 = 0.0;
for run in &mut runs {
run.x_offset = x_cursor;
x_cursor += run.width;
max_height = max_height.max(run.line_height);
}
finish_line(
runs,
Vec::new(),
x_cursor,
origin_x,
origin_y,
max_height,
fonts,
)
}
fn measure_space_width(fonts: &FontCollection, font_index: usize, font_size: f32) -> f32 {
let font = match fonts.get(font_index) {
Some(font) if font.has_outlines() => Some(font),
_ => fonts.fonts().iter().find(|font| font.has_outlines()),
};
font.map(|font| measure_text(font, " ", font_size))
.unwrap_or(0.0)
}
fn line_height_for(runs: &[TextRun]) -> f32 {
runs.iter().map(|r| r.line_height).fold(0.0f32, f32::max)
}
pub(super) fn finish_line(
mut runs: Vec<TextRun>,
mut atomics: Vec<AtomicInline>,
width: f32,
x: f32,
y: f32,
height: f32,
fonts: &FontCollection,
) -> LineBox {
resolve_baseline_shifts(&mut runs, fonts);
let middle_x_height = runs.first().map(|first| {
fonts
.get(first.font_index)
.map(|f| f.x_height(first.font_size))
.unwrap_or(first.font_size * 0.5)
});
for atomic in atomics.iter_mut() {
atomic.baseline_shift = match atomic.vertical_align {
VerticalAlign::LengthPercentage(LengthPercentage::Length(px)) => px,
VerticalAlign::LengthPercentage(LengthPercentage::Percentage(fraction)) => {
height * fraction
}
VerticalAlign::Middle => middle_x_height.map_or(0.0, |x_height| {
let ascent = atomic.margin_box_height - atomic.baseline_from_bottom;
x_height / 2.0 - (ascent - atomic.baseline_from_bottom) / 2.0
}),
_ => 0.0,
};
}
let mut baseline = 0.0f32;
for run in &runs {
let half_leading = (height - (run.ascent + run.descent)) / 2.0;
baseline = baseline.max(half_leading + run.ascent);
}
let mut above = baseline;
let mut below = height - baseline;
for atomic in atomics
.iter()
.filter(|a| !matches!(a.vertical_align, VerticalAlign::Top | VerticalAlign::Bottom))
{
above = above
.max(atomic.margin_box_height - atomic.baseline_from_bottom + atomic.baseline_shift);
below = below.max(atomic.baseline_from_bottom - atomic.baseline_shift);
}
for run in runs.iter().filter(|r| {
r.baseline_shift != 0.0
&& !matches!(r.vertical_align, VerticalAlign::Top | VerticalAlign::Bottom)
}) {
above = above.max(run.ascent + run.baseline_shift);
below = below.max(run.descent - run.baseline_shift);
}
for atomic in &atomics {
let shortfall = atomic.margin_box_height - (above + below);
if shortfall <= 0.0 {
continue;
}
match atomic.vertical_align {
VerticalAlign::Top => below += shortfall,
VerticalAlign::Bottom => above += shortfall,
_ => {}
}
}
let line_height = if runs.is_empty() && atomics.is_empty() {
height
} else {
above + below
};
let baseline = if runs.is_empty() && atomics.is_empty() {
0.0
} else {
above
};
for run in &mut runs {
match run.vertical_align {
VerticalAlign::Top => run.baseline_shift = baseline - run.ascent,
VerticalAlign::Bottom => run.baseline_shift = -(line_height - baseline - run.descent),
_ => {}
}
}
for atomic in &mut atomics {
match atomic.vertical_align {
VerticalAlign::Top => {
atomic.baseline_shift =
baseline - (atomic.margin_box_height - atomic.baseline_from_bottom);
}
VerticalAlign::Bottom => {
atomic.baseline_shift = -(line_height - baseline) + atomic.baseline_from_bottom
}
_ => {}
}
}
LineBox {
rect: Rect {
x,
y,
width,
height: line_height,
},
runs,
baseline,
atomics,
}
}
fn first_baseline_from_bottom(b: &LaidOutBox) -> Option<f32> {
fn first_baseline_y(b: &LaidOutBox) -> Option<f32> {
match &b.content {
LaidOutContent::Inline(lines) => lines
.iter()
.find(|l| !l.runs.is_empty())
.map(|l| l.rect.y + l.baseline),
LaidOutContent::Blocks(items) | LaidOutContent::Flex(items) => {
items.iter().find_map(first_baseline_y)
}
_ => None,
}
}
let layout = &b.layout;
let margin_bottom_y = layout.content.y
+ layout.content.height
+ layout.padding.bottom
+ layout.border.bottom
+ layout.margin.bottom;
first_baseline_y(b).map(|y| (margin_bottom_y - y).max(0.0))
}
fn layout_atomic_inline(
b: &LayoutBox,
styles: &HashMap<NodeId, Rc<ComputedStyle>>,
fonts: &FontCollection,
available_width: f32,
pos: &mut PosCtx,
) -> LaidOutBox {
let mut style = b
.node
.and_then(|n| styles.get(&n))
.map(|shared| (**shared).clone())
.unwrap_or_default();
super::block::resolve_percentage_heights(&mut style, pos.cb_height());
if let BoxContent::Image(image_content) = &b.content {
super::block::apply_replaced_element_auto_size(&mut style, image_content, available_width);
}
let padding = super::block::resolve_padding(&style, available_width);
let border = super::block::resolve_border(&style);
let content_width = match style.width {
LengthPercentageOrAuto::LengthPercentage(lp) => {
let width = super::block::resolve_lp(lp, available_width);
if style.box_sizing == BoxSizing::BorderBox {
(width - padding.left - padding.right - border.left - border.right).max(0.0)
} else {
width
}
}
LengthPercentageOrAuto::Auto => {
let outer = padding.left + padding.right + border.left + border.right;
super::block::aspect_ratio_width(&style, &padding, &border).unwrap_or_else(|| {
super::block::shrink_to_fit_content_width(
b,
styles,
fonts,
&style,
(available_width - outer).max(0.0),
)
})
}
};
let content_width = super::block::clamp_used_width(
&style,
available_width,
padding.left + padding.right,
border.left + border.right,
content_width,
);
let mut float_ctx = FloatContext::new();
super::block::layout_box_with_forced_width(
b,
styles,
fonts,
available_width,
content_width,
&mut float_ctx,
0.0,
0.0,
pos,
)
}
fn margin_box_width_of(b: &LaidOutBox) -> f32 {
let border_box = b.layout.border_box();
b.layout.margin.left + border_box.width + b.layout.margin.right
}
fn resolve_baseline_shifts(runs: &mut [TextRun], fonts: &FontCollection) {
let Some(first) = runs.first() else {
return;
};
let base_ascent = first.ascent;
let base_descent = first.descent;
let base_x_height = fonts
.get(first.font_index)
.map(|f| f.x_height(first.font_size))
.unwrap_or(first.font_size * 0.5);
for run in runs.iter_mut() {
run.baseline_shift = match run.vertical_align {
VerticalAlign::Baseline | VerticalAlign::Top | VerticalAlign::Bottom => 0.0,
VerticalAlign::Sub => -fonts
.get(run.font_index)
.map(|f| f.subscript_offset(run.font_size))
.unwrap_or(run.font_size * 0.2),
VerticalAlign::Super => fonts
.get(run.font_index)
.map(|f| f.superscript_offset(run.font_size))
.unwrap_or(run.font_size * 0.33),
VerticalAlign::TextTop => base_ascent - run.ascent,
VerticalAlign::TextBottom => run.descent - base_descent,
VerticalAlign::Middle => base_x_height / 2.0 - (run.ascent - run.descent) / 2.0,
VerticalAlign::LengthPercentage(lp) => resolve_length_percentage(lp, run.line_height),
};
}
}
pub(super) fn plain_text_min_content_width(
spans: &[InlineSpan],
styles: &HashMap<NodeId, Rc<ComputedStyle>>,
fonts: &FontCollection,
natural_width: impl FnOnce() -> f32,
) -> Option<f32> {
let [span] = spans else {
return None;
};
if span.atomic.is_some() || span.is_forced_break || span.is_first_letter {
return None;
}
let style = styles.get(&span.node)?;
if style.white_space != WhiteSpace::Normal
|| style.word_break != WordBreak::Normal
|| style.overflow_wrap != OverflowWrap::Normal
|| style.text_transform != TextTransform::None
|| fonts.is_empty()
|| style.text_indent != LengthPercentage::Length(0.0)
{
return None;
}
let mut words = span
.text
.split(white_space::is_collapsible)
.filter(|w| !w.is_empty());
if let (Some(word), None) = (words.next(), words.next()) {
if word.chars().all(|c| c.is_ascii_graphic()) {
return Some(natural_width());
}
}
let font_size = style.font_size.0;
let mut widest = 0.0f32;
for word in span
.text
.split(white_space::is_collapsible)
.filter(|w| !w.is_empty())
{
let mut font_index = None;
for c in word.chars() {
if !c.is_ascii_graphic() {
return None;
}
let selected = select_ascii_font(fonts, style, c);
if *font_index.get_or_insert(selected) != selected {
return None;
}
}
let font_index = font_index?;
let key = (
font_index,
font_size.to_bits(),
style.letter_spacing.to_bits(),
);
let cached = WORD_WIDTH_CACHE.with(|cache| {
cache
.borrow()
.get(&key)
.and_then(|words| words.get(word).copied())
});
let width = match cached {
Some(width) => width,
None => {
let shaped = shape_text(fonts.get(font_index)?, word, font_size);
let width = shaped.width + style.letter_spacing * shaped.glyphs.len() as f32;
WORD_WIDTH_CACHE.with(|cache| {
cache
.borrow_mut()
.entry(key)
.or_default()
.insert(word.to_string(), width);
});
width
}
};
widest = widest.max(width);
}
Some(widest)
}
type WordWidths = HashMap<(usize, u32, u32), HashMap<String, f32>>;
thread_local! {
static ASCII_FONT_CACHE: std::cell::RefCell<Vec<AsciiFontEntry>> =
const { std::cell::RefCell::new(Vec::new()) };
static WORD_WIDTH_CACHE: std::cell::RefCell<WordWidths> =
std::cell::RefCell::new(HashMap::new());
}
fn select_ascii_font(fonts: &FontCollection, style: &ComputedStyle, c: char) -> usize {
ASCII_FONT_CACHE.with(|cache| {
let mut cache = cache.borrow_mut();
let entry = match cache.iter().position(|e| {
e.weight == style.font_weight
&& e.style == style.font_style
&& e.families == style.font_family
}) {
Some(i) => &mut cache[i],
None => {
cache.push(AsciiFontEntry {
families: style.font_family.clone(),
weight: style.font_weight,
style: style.font_style,
selected: [None; 128],
});
cache.last_mut().expect("just pushed")
}
};
let slot = &mut entry.selected[c as usize & 127];
*slot.get_or_insert_with(|| {
fonts
.select_for_char(&style.font_family, style.font_weight, style.font_style, c)
.unwrap_or(0)
})
})
}
struct AsciiFontEntry {
families: Vec<String>,
weight: FontWeight,
style: FontStyle,
selected: [Option<usize>; 128],
}
pub(super) fn clear_word_width_cache() {
WORD_WIDTH_CACHE.with(|cache| cache.borrow_mut().clear());
ASCII_FONT_CACHE.with(|cache| cache.borrow_mut().clear());
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fonts::Font;
use crate::html::{self, Dom};
use crate::layout::box_tree::{build_box_tree, BoxContent, LayoutBox};
use crate::style::{compute_styles, parse_stylesheet, user_agent_stylesheet};
#[allow(clippy::too_many_arguments)]
fn layout_inline_content(
spans: &[InlineSpan],
styles: &HashMap<NodeId, Rc<ComputedStyle>>,
fonts: &FontCollection,
available_width: f32,
origin_x: f32,
origin_y: f32,
float_ctx: Option<&FloatContext>,
) -> Vec<LineBox> {
let mut discarded = Vec::new();
let mut pos = PosCtx::new(&mut discarded, (0.0, 0.0));
super::layout_inline_content(
spans,
styles,
fonts,
available_width,
origin_x,
origin_y,
float_ctx,
None,
&mut pos,
)
}
const DEJAVU_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fonts/DejaVuSans.ttf");
const DEJAVU_BOLD_PATH: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fonts/DejaVuSans-Bold.ttf"
);
const CJK_PATH: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fonts/NotoSansCJK-Regular.ttc"
);
fn dejavu_only() -> FontCollection {
FontCollection::new(vec![Font::load(DEJAVU_PATH).unwrap()])
}
fn default_line_height(fonts: &FontCollection) -> f32 {
fonts
.get(0)
.expect("there is always at least one test font")
.normal_line_height(ComputedStyle::default().font_size.0)
}
fn dejavu_regular_and_bold() -> FontCollection {
FontCollection::new(vec![
Font::load(DEJAVU_PATH).unwrap(),
Font::load(DEJAVU_BOLD_PATH).unwrap(),
])
}
fn dejavu_and_cjk() -> FontCollection {
FontCollection::new(vec![
Font::load(DEJAVU_PATH).unwrap(),
Font::load_indexed(CJK_PATH, 0).unwrap(),
])
}
fn find_inline_spans(b: &LayoutBox) -> Option<&Vec<InlineSpan>> {
match &b.content {
BoxContent::Inline(spans) => Some(spans),
BoxContent::Blocks(children) => children.iter().find_map(find_inline_spans),
BoxContent::Grid(grid) => grid.items.iter().find_map(find_inline_spans),
BoxContent::Table(table) => table
.caption
.as_deref()
.and_then(find_inline_spans)
.or_else(|| {
table
.rows
.iter()
.flat_map(|row| &row.cells)
.find_map(|cell| find_inline_spans(&cell.content))
}),
BoxContent::Flex(flex) => flex.items.iter().find_map(find_inline_spans),
BoxContent::Image(_) => None,
}
}
fn spans_for(
inner_html: &str,
css: &str,
) -> (Dom, Vec<InlineSpan>, HashMap<NodeId, Rc<ComputedStyle>>) {
let html_src = format!("<p>{inner_html}</p>");
let dom = html::parse(html_src.as_bytes());
let ua = user_agent_stylesheet();
let author = parse_stylesheet(css);
let styles = compute_styles(&dom, &ua, &author);
let tree = build_box_tree(&dom, &styles);
let spans = find_inline_spans(&tree)
.expect("expected inline content")
.clone();
(dom, spans, styles)
}
#[test]
fn empty_or_whitespace_only_text_produces_no_lines() {
let (_, spans, styles) = spans_for("", "");
let fonts = dejavu_only();
assert!(layout_inline_content(&spans, &styles, &fonts, 200.0, 0.0, 0.0, None).is_empty());
let (_, spans, styles) = spans_for(" \n\t ", "");
assert!(layout_inline_content(&spans, &styles, &fonts, 200.0, 0.0, 0.0, None).is_empty());
}
#[test]
fn empty_font_collection_produces_no_lines() {
let (_, spans, styles) = spans_for("hello", "");
let fonts = FontCollection::new(vec![]);
assert!(layout_inline_content(&spans, &styles, &fonts, 200.0, 0.0, 0.0, None).is_empty());
}
#[test]
fn text_that_fits_stays_on_a_single_line() {
let (_, spans, styles) = spans_for("hello world", "");
let fonts = dejavu_only();
let lines = layout_inline_content(&spans, &styles, &fonts, 500.0, 10.0, 20.0, None);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].rect.x, 10.0);
assert_eq!(lines[0].rect.y, 20.0);
assert!(lines[0].rect.width > 0.0);
assert_eq!(lines[0].rect.height, default_line_height(&fonts));
assert_eq!(lines[0].runs.len(), 1);
assert_eq!(lines[0].runs[0].text, "hello world");
assert!(lines[0].runs.iter().all(|r| r.font_index == 0));
}
#[test]
fn first_letter_style_overrides_are_applied_only_to_the_split_off_run() {
let (_, spans, styles) = spans_for(
"Hello world",
"p::first-letter { font-size: 2em; color: rgb(200, 0, 0); font-weight: bold; }",
);
let fonts = dejavu_only();
let lines = layout_inline_content(&spans, &styles, &fonts, 500.0, 0.0, 0.0, None);
assert_eq!(lines.len(), 1);
let runs = &lines[0].runs;
assert!(runs.len() >= 2, "first-letter run + remainder run(s)");
let base_font_size = ComputedStyle::default().font_size.0;
assert_eq!(runs[0].text, "H");
assert_eq!(runs[0].font_size, base_font_size * 2.0);
assert_eq!(
runs[0].color,
RgbaColor {
red: 200,
green: 0,
blue: 0,
alpha: 1.0
}
);
assert!(runs[0].bold);
let remainder: String = runs[1..].iter().map(|r| r.text.as_str()).collect();
assert_eq!(remainder, "ello world");
assert_eq!(runs[1].font_size, base_font_size);
assert_eq!(runs[1].color, ComputedStyle::default().color);
assert!(!runs[1].bold);
}
#[test]
fn wraps_to_a_new_line_when_available_width_is_too_narrow() {
let fonts = dejavu_only();
let (_, spans, styles) = spans_for("hello world foo bar", "");
let one_line = layout_inline_content(&spans, &styles, &fonts, 1000.0, 0.0, 0.0, None);
assert_eq!(one_line.len(), 1);
let wrapped = layout_inline_content(&spans, &styles, &fonts, 60.0, 0.0, 0.0, None);
assert!(wrapped.len() > 1);
let line_height = default_line_height(&fonts);
assert_eq!(wrapped[1].rect.y, wrapped[0].rect.y + line_height);
}
#[test]
fn float_narrows_the_band_for_lines_overlapping_it() {
use crate::style::Float;
let fonts = dejavu_only();
let (_, spans, styles) = spans_for("hello world foo bar", "");
let mut ctx = FloatContext::new();
ctx.register(Float::Left, 0.0, 0.0, 400.0, 1000.0);
let lines = layout_inline_content(&spans, &styles, &fonts, 500.0, 0.0, 0.0, Some(&ctx));
assert!(!lines.is_empty());
for line in &lines {
assert_eq!(line.rect.x, 400.0);
assert!(
line.rect.width <= 100.0,
"line width {} should not exceed the 100px band beside the float",
line.rect.width
);
}
}
#[test]
fn line_widens_back_after_passing_the_bottom_of_the_float() {
use crate::style::Float;
let fonts = dejavu_only();
let (_, spans, styles) = spans_for("hello world foo bar baz", "");
let line_height = default_line_height(&fonts);
let mut ctx = FloatContext::new();
ctx.register(Float::Left, 0.0, 0.0, 400.0, line_height);
let lines = layout_inline_content(&spans, &styles, &fonts, 500.0, 0.0, 0.0, Some(&ctx));
assert!(lines.len() >= 2, "expected wrapping to at least 2 lines");
assert_eq!(lines[0].rect.x, 400.0);
assert_eq!(
lines[1].rect.x, 0.0,
"second line should return to the full width once below the float"
);
}
#[test]
fn no_float_context_behaves_like_the_unconstrained_case() {
let fonts = dejavu_only();
let (_, spans, styles) = spans_for("hello world", "");
let with_none = layout_inline_content(&spans, &styles, &fonts, 500.0, 0.0, 0.0, None);
let empty_ctx = FloatContext::new();
let with_empty_ctx =
layout_inline_content(&spans, &styles, &fonts, 500.0, 0.0, 0.0, Some(&empty_ctx));
assert_eq!(with_none.len(), with_empty_ctx.len());
for (a, b) in with_none.iter().zip(with_empty_ctx.iter()) {
assert_eq!(a.rect, b.rect);
assert_eq!(a.baseline, b.baseline);
assert_eq!(
line_texts(std::slice::from_ref(a)),
line_texts(std::slice::from_ref(b))
);
}
}
#[test]
fn overlong_single_word_is_not_split_and_still_placed() {
let (_, spans, styles) = spans_for("supercalifragilisticexpialidocious", "");
let fonts = dejavu_only();
let lines = layout_inline_content(&spans, &styles, &fonts, 10.0, 0.0, 0.0, None);
assert_eq!(lines.len(), 1);
assert!(
lines[0].rect.width > 10.0,
"overflowing word should not be dropped or split"
);
}
#[test]
fn collapses_runs_of_whitespace_between_words() {
let (_, spans, styles) = spans_for("a b\n\tc", "");
let fonts = dejavu_only();
let lines = layout_inline_content(&spans, &styles, &fonts, 500.0, 0.0, 0.0, None);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].runs.len(), 1);
assert_eq!(lines[0].runs[0].text, "a b c");
}
#[test]
fn mixed_script_word_splits_into_separate_font_runs() {
let (_, spans, styles) = spans_for("café日本語", "");
let fonts = dejavu_and_cjk();
let lines = layout_inline_content(&spans, &styles, &fonts, 500.0, 0.0, 0.0, None);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].runs.len(), 2, "two runs: café and the Japanese");
assert_eq!(
lines[0].runs[0].font_index, 0,
"café should use DejaVu Sans"
);
assert_eq!(lines[0].runs[0].text, "café");
assert_eq!(
lines[0].runs[1].font_index, 1,
"日本語 should use the CJK fallback font"
);
assert_eq!(lines[0].runs[1].text, "日本語");
let mut prev_end = lines[0].runs[0].x_offset + lines[0].runs[0].width;
for run in &lines[0].runs[1..] {
assert_eq!(run.x_offset, prev_end);
prev_end = run.x_offset + run.width;
}
}
#[test]
fn separate_cjk_and_latin_words_can_land_on_the_same_line() {
let (_, spans, styles) = spans_for("Invoice 請求書", "");
let fonts = dejavu_and_cjk();
let lines = layout_inline_content(&spans, &styles, &fonts, 500.0, 0.0, 0.0, None);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].runs.len(), 2);
assert_eq!(lines[0].runs[0].font_index, 0);
assert_eq!(lines[0].runs[0].text, "Invoice");
assert_eq!(lines[0].runs[1].font_index, 1);
assert_eq!(lines[0].runs[1].text, "請求書");
}
#[test]
fn long_cjk_sequence_wraps_between_characters_without_whitespace() {
let (_, spans, styles) = spans_for("日本語のテスト文章です", "");
let fonts = dejavu_and_cjk();
let narrow = layout_inline_content(&spans, &styles, &fonts, 60.0, 0.0, 0.0, None);
assert!(
narrow.len() > 1,
"a narrow line width should force wrapping within the CJK sequence"
);
for line in &narrow {
assert!(
!line.runs.is_empty(),
"every wrapped line should contain at least one run"
);
}
let wide = layout_inline_content(&spans, &styles, &fonts, 2000.0, 0.0, 0.0, None);
assert_eq!(
wide.len(),
1,
"a wide enough line should keep the whole sequence on one line"
);
}
#[test]
fn cafe_nihongo_wraps_between_the_script_boundary_when_narrow() {
let (_, spans, styles) = spans_for("café日本語", "");
let fonts = dejavu_and_cjk();
let single_line = layout_inline_content(&spans, &styles, &fonts, 10000.0, 0.0, 0.0, None);
let cafe_width = single_line[0].runs[0].width;
let lines =
layout_inline_content(&spans, &styles, &fonts, cafe_width + 1.0, 0.0, 0.0, None);
assert!(
lines.len() > 1,
"should wrap at the café/日 boundary instead of overflowing as one unbreakable word"
);
assert_eq!(lines[0].runs.len(), 1);
assert_eq!(lines[0].runs[0].text, "café");
}
#[test]
fn bold_span_in_the_middle_of_a_word_splits_into_separate_runs() {
let (_, spans, styles) = spans_for("bo<b>ld</b>", "");
let fonts = dejavu_only();
let lines = layout_inline_content(&spans, &styles, &fonts, 500.0, 0.0, 0.0, None);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].runs.len(), 2, "should split at the <b> boundary");
assert!(!lines[0].runs[0].bold);
assert!(lines[0].runs[1].bold);
assert_eq!(lines[0].runs[0].text, "bo");
assert_eq!(lines[0].runs[1].text, "ld");
}
#[test]
fn bold_span_uses_the_real_bold_face_and_skips_synthetic_bold_when_available() {
let (_, spans, styles) = spans_for("bo<b>ld</b>", "p { font-family: 'DejaVu Sans'; }");
let fonts = dejavu_regular_and_bold();
let lines = layout_inline_content(&spans, &styles, &fonts, 500.0, 0.0, 0.0, None);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].runs.len(), 2);
assert_eq!(
lines[0].runs[0].font_index, 0,
"\"bo\" (normal weight) should use the regular face"
);
assert!(!lines[0].runs[0].bold);
assert_eq!(
lines[0].runs[1].font_index, 1,
"\"ld\" (bold) should use the real bold face, not the regular one"
);
assert!(
!lines[0].runs[1].bold,
"no synthetic bold should be applied when a real bold face was selected"
);
}
#[test]
fn bold_span_prefers_the_real_bold_face_even_without_a_matching_font_family() {
let (_, spans, styles) = spans_for("bo<b>ld</b>", "");
let fonts = dejavu_regular_and_bold();
let lines = layout_inline_content(&spans, &styles, &fonts, 500.0, 0.0, 0.0, None);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].runs.len(), 2);
assert_eq!(lines[0].runs[0].font_index, 0);
assert_eq!(
lines[0].runs[1].font_index, 1,
"bold text should still find the real bold face via the family-agnostic fallback"
);
assert!(!lines[0].runs[1].bold);
}
#[test]
fn text_transform_uppercase_and_lowercase_apply_to_every_character() {
let (_, spans, styles) = spans_for("Hello World", "p { text-transform: uppercase; }");
let fonts = dejavu_only();
let lines = layout_inline_content(&spans, &styles, &fonts, 500.0, 0.0, 0.0, None);
let text: String = lines[0].runs.iter().map(|r| r.text.as_str()).collect();
assert_eq!(text, "HELLO WORLD");
let (_, spans, styles) = spans_for("Hello World", "p { text-transform: lowercase; }");
let lines = layout_inline_content(&spans, &styles, &fonts, 500.0, 0.0, 0.0, None);
let text: String = lines[0].runs.iter().map(|r| r.text.as_str()).collect();
assert_eq!(text, "hello world");
}
#[test]
fn text_transform_capitalize_affects_only_the_first_letter_of_each_word() {
let (_, spans, styles) = spans_for("hello world", "p { text-transform: capitalize; }");
let fonts = dejavu_only();
let lines = layout_inline_content(&spans, &styles, &fonts, 500.0, 0.0, 0.0, None);
let text: String = lines[0].runs.iter().map(|r| r.text.as_str()).collect();
assert_eq!(text, "Hello World");
}
#[test]
fn text_transform_capitalize_treats_a_span_boundary_as_a_word_start() {
let (_, spans, styles) =
spans_for("hello <b>world</b>", "p { text-transform: capitalize; }");
let fonts = dejavu_only();
let lines = layout_inline_content(&spans, &styles, &fonts, 500.0, 0.0, 0.0, None);
let text: String = lines[0].runs.iter().map(|r| r.text.as_str()).collect();
assert_eq!(text, "HelloWorld");
}
#[test]
fn word_spacing_widens_the_gap_between_words() {
let (_, spans, styles) = spans_for("hello world", "");
let fonts = dejavu_only();
let without = layout_inline_content(&spans, &styles, &fonts, 500.0, 0.0, 0.0, None);
let (_, spans, styles) = spans_for("hello world", "p { word-spacing: 20px; }");
let with = layout_inline_content(&spans, &styles, &fonts, 500.0, 0.0, 0.0, None);
let width_without = without[0].rect.width;
let width_with = with[0].rect.width;
assert!(
width_with > width_without,
"word-spacing should widen the gap: without={width_without}, with={width_with}"
);
}
#[test]
fn letter_spacing_widens_run_width_by_glyph_count() {
let (_, spans, styles) = spans_for("hello", "");
let fonts = dejavu_only();
let without = layout_inline_content(&spans, &styles, &fonts, 500.0, 0.0, 0.0, None);
let (_, spans, styles) = spans_for("hello", "p { letter-spacing: 2px; }");
let with = layout_inline_content(&spans, &styles, &fonts, 500.0, 0.0, 0.0, None);
let glyph_count = with[0].runs[0].glyphs.len() as f32;
assert_eq!(
with[0].runs[0].width,
without[0].runs[0].width + 2.0 * glyph_count
);
assert_eq!(with[0].runs[0].letter_spacing, 2.0);
}
#[test]
fn white_space_nowrap_does_not_wrap_even_when_overflowing() {
let (_, spans, styles) = spans_for("hello world foo bar", "p { white-space: nowrap; }");
let fonts = dejavu_only();
let lines = layout_inline_content(&spans, &styles, &fonts, 60.0, 0.0, 0.0, None);
assert_eq!(
lines.len(),
1,
"nowrap should keep everything on a single line even when it overflows"
);
assert!(lines[0].rect.width > 60.0);
}
#[test]
fn white_space_pre_preserves_explicit_newlines_and_does_not_wrap() {
let (_, spans, styles) = spans_for(
"hello world this is a long line",
"p { white-space: pre; }",
);
let fonts = dejavu_only();
let lines = layout_inline_content(&spans, &styles, &fonts, 60.0, 0.0, 10.0, None);
assert_eq!(lines.len(), 2, "should split only at the explicit newline");
assert_eq!(lines[0].runs.len(), 1);
assert_eq!(lines[0].runs[0].text, "hello");
assert!(
lines[1].rect.width > 60.0,
"the second physical line should not wrap despite overflowing"
);
let line_height = default_line_height(&fonts);
assert_eq!(lines[1].rect.y, lines[0].rect.y + line_height);
}
#[test]
fn white_space_pre_preserves_runs_of_whitespace() {
let (_, spans, styles) = spans_for("a b", "p { white-space: pre; }");
let fonts = dejavu_only();
let lines = layout_inline_content(&spans, &styles, &fonts, 500.0, 0.0, 0.0, None);
assert_eq!(lines.len(), 1);
let text: String = lines[0].runs.iter().map(|r| r.text.as_str()).collect();
assert_eq!(text, "a b", "runs of whitespace should not be collapsed");
}
#[test]
fn white_space_pre_preserves_leading_whitespace_before_an_inline_element() {
let (_, spans, styles) = spans_for(" <b>x</b>y", "p { white-space: pre; }");
let fonts = dejavu_only();
let lines = layout_inline_content(&spans, &styles, &fonts, 500.0, 0.0, 0.0, None);
assert_eq!(lines.len(), 1);
let text: String = lines[0].runs.iter().map(|r| r.text.as_str()).collect();
assert_eq!(text, " xy", "the indentation should be preserved");
}
#[test]
fn white_space_pre_consecutive_newlines_produce_an_empty_line() {
let (_, spans, styles) = spans_for("a b", "p { white-space: pre; }");
let fonts = dejavu_only();
let lines = layout_inline_content(&spans, &styles, &fonts, 500.0, 0.0, 0.0, None);
assert_eq!(
lines.len(),
3,
"two newlines should produce 3 physical lines"
);
assert!(lines[1].runs.is_empty(), "the middle line should be empty");
assert!(
lines[1].rect.height > 0.0,
"an empty line still consumes height"
);
}
#[test]
fn text_align_left_is_the_default_and_does_not_shift_runs() {
let (_, spans, styles) = spans_for("hi", "");
let fonts = dejavu_only();
let lines = layout_inline_content(&spans, &styles, &fonts, 500.0, 0.0, 0.0, None);
assert_eq!(lines[0].runs[0].x_offset, 0.0);
}
#[test]
fn text_align_right_pushes_the_line_to_the_right_edge() {
let (_, spans, styles) = spans_for("hi", "p { text-align: right; }");
let fonts = dejavu_only();
let lines = layout_inline_content(&spans, &styles, &fonts, 500.0, 0.0, 0.0, None);
let content_width = lines[0].rect.width;
assert_eq!(lines[0].runs[0].x_offset, 500.0 - content_width);
}
#[test]
fn text_align_center_splits_the_leftover_space_evenly() {
let (_, spans, styles) = spans_for("hi", "p { text-align: center; }");
let fonts = dejavu_only();
let lines = layout_inline_content(&spans, &styles, &fonts, 500.0, 0.0, 0.0, None);
let content_width = lines[0].rect.width;
assert_eq!(lines[0].runs[0].x_offset, (500.0 - content_width) / 2.0);
}
#[test]
fn text_align_justify_spreads_extra_space_across_word_gaps_but_not_on_the_last_line() {
let (_, spans, styles) = spans_for("hello world foo bar baz", "p { text-align: justify; }");
let fonts = dejavu_only();
let lines = layout_inline_content(&spans, &styles, &fonts, 150.0, 0.0, 0.0, None);
assert!(lines.len() >= 2, "expected wrapping to at least 2 lines");
for line in &lines[..lines.len() - 1] {
let text: String = line.runs.iter().map(|r| r.text.as_str()).collect();
assert!(
text.contains(' '),
"a justified non-last line needs at least one word gap to stretch"
);
assert_eq!(
line.rect.width, 150.0,
"non-last justified lines should stretch to fill the available width"
);
}
let last = lines.last().unwrap();
assert!(
last.rect.width < 150.0,
"the last line should not be stretched by justify"
);
}
#[test]
fn text_align_justify_does_not_push_text_over_an_inline_block_on_the_same_line() {
let (_, spans, styles) = spans_for(
r#"aa bb <input style="width: 40px;"> cc dddddddddddddddddddddd"#,
"p { text-align: justify; }",
);
let fonts = dejavu_only();
let lines = layout_inline_content(&spans, &styles, &fonts, 180.0, 0.0, 0.0, None);
assert!(lines.len() >= 2, "expected the long word to wrap");
let line = &lines[0];
assert_eq!(line.atomics.len(), 1, "the box should be on the first line");
assert_eq!(line.rect.width, 180.0, "the first line should be justified");
let atomic = &line.atomics[0];
let box_left = atomic.x_offset;
let box_right = atomic.x_offset + atomic.margin_box_width;
for run in &line.runs {
let run_right = run.x_offset + run.width;
assert!(
run_right <= box_left + 0.01 || run.x_offset >= box_right - 0.01,
"run {:?} at [{}, {}] overlaps the box at [{}, {}]",
run.text,
run.x_offset,
run_right,
box_left,
box_right
);
}
let before = line
.runs
.iter()
.filter(|r| r.x_offset < box_left)
.max_by(|a, b| a.x_offset.total_cmp(&b.x_offset))
.expect("a run before the box");
let after = line
.runs
.iter()
.filter(|r| r.x_offset >= box_right - 0.01)
.min_by(|a, b| a.x_offset.total_cmp(&b.x_offset))
.expect("a run after the box");
let gap_before = box_left - (before.x_offset + before.width);
let gap_after = after.x_offset - box_right;
assert!(
(gap_before - gap_after).abs() < 0.01,
"expected equal gaps around the box, got before={gap_before} after={gap_after}"
);
}
#[test]
fn text_align_justify_does_not_open_a_gap_around_a_box_written_without_spaces() {
let (_, spans, styles) = spans_for(
r#"aaa<input style="width: 40px;">bbb ccc dddddddddddddddddddddd"#,
"p { text-align: justify; }",
);
let fonts = dejavu_only();
let lines = layout_inline_content(&spans, &styles, &fonts, 180.0, 0.0, 0.0, None);
assert!(lines.len() >= 2, "expected the long word to wrap");
let line = &lines[0];
assert_eq!(line.rect.width, 180.0, "the first line should be justified");
let atomic = &line.atomics[0];
let box_left = atomic.x_offset;
let box_right = atomic.x_offset + atomic.margin_box_width;
let before = line
.runs
.iter()
.filter(|r| r.x_offset < box_left)
.max_by(|a, b| a.x_offset.total_cmp(&b.x_offset))
.expect("a run before the box");
let after = line
.runs
.iter()
.filter(|r| r.x_offset >= box_right - 0.01)
.min_by(|a, b| a.x_offset.total_cmp(&b.x_offset))
.expect("a run after the box");
assert!(
(box_left - (before.x_offset + before.width)).abs() < 0.01,
"expected `aaa` to touch the box, got a gap of {}",
box_left - (before.x_offset + before.width)
);
assert!(
(after.x_offset - box_right).abs() < 0.01,
"expected `bbb` to touch the box, got a gap of {}",
after.x_offset - box_right
);
}
#[test]
fn text_align_justify_with_a_single_word_line_does_not_panic_or_shift() {
let (_, spans, styles) = spans_for(
"supercalifragilisticexpialidocious",
"p { text-align: justify; }",
);
let fonts = dejavu_only();
let lines = layout_inline_content(&spans, &styles, &fonts, 10.0, 0.0, 0.0, None);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].runs[0].x_offset, 0.0);
}
#[test]
fn text_indent_px_shifts_only_the_first_line() {
let (_, spans, styles) = spans_for("hello world foo bar", "p { text-indent: 30px; }");
let fonts = dejavu_only();
let lines = layout_inline_content(&spans, &styles, &fonts, 60.0, 0.0, 0.0, None);
assert!(lines.len() >= 2, "expected wrapping to at least 2 lines");
assert_eq!(lines[0].rect.x, 30.0);
assert_eq!(lines[1].rect.x, 0.0, "second line should not be indented");
}
#[test]
fn text_indent_percentage_resolves_against_available_width() {
let (_, spans, styles) = spans_for("hi", "p { text-indent: 10%; }");
let fonts = dejavu_only();
let lines = layout_inline_content(&spans, &styles, &fonts, 500.0, 0.0, 0.0, None);
assert_eq!(lines[0].rect.x, 50.0);
}
#[test]
fn text_indent_applies_to_the_first_physical_line_of_pre_content() {
let (_, spans, styles) = spans_for(
"hello world",
"p { white-space: pre; text-indent: 15px; }",
);
let fonts = dejavu_only();
let lines = layout_inline_content(&spans, &styles, &fonts, 500.0, 0.0, 0.0, None);
assert_eq!(lines.len(), 2);
assert_eq!(lines[0].rect.x, 15.0);
assert_eq!(lines[1].rect.x, 0.0);
}
#[test]
fn inline_span_color_and_style_are_carried_onto_the_text_run() {
let (_, spans, styles) = spans_for(
r#"plain <em style="color: rgb(200, 0, 0);">urgent</em>"#,
"",
);
let fonts = dejavu_only();
let lines = layout_inline_content(&spans, &styles, &fonts, 500.0, 0.0, 0.0, None);
assert_eq!(lines.len(), 1);
let plain_run = lines[0]
.runs
.iter()
.find(|r| r.text == "plain")
.expect("plain run not found");
assert!(!plain_run.italic);
assert_eq!(plain_run.color, ComputedStyle::default().color);
let urgent_run = lines[0]
.runs
.iter()
.find(|r| r.text == "urgent")
.expect("urgent run not found");
assert!(urgent_run.italic, "<em> should render in italic");
assert_eq!(
urgent_run.color,
RgbaColor {
red: 200,
green: 0,
blue: 0,
alpha: 1.0
}
);
}
fn line_texts(lines: &[LineBox]) -> Vec<String> {
lines
.iter()
.map(|line| line.runs.iter().map(|r| r.text.as_str()).collect())
.collect()
}
#[test]
fn br_breaks_the_line_even_when_the_text_would_fit() {
let (_, spans, styles) = spans_for("hello<br>world", "");
let fonts = dejavu_only();
let lines = layout_inline_content(&spans, &styles, &fonts, 5000.0, 0.0, 0.0, None);
assert_eq!(line_texts(&lines), vec!["hello", "world"]);
assert!(
lines[1].rect.y > lines[0].rect.y,
"the second line must be placed below the first"
);
}
#[test]
fn br_breaks_even_with_white_space_nowrap() {
let (_, spans, styles) = spans_for("hello<br>world", "p { white-space: nowrap; }");
let fonts = dejavu_only();
let lines = layout_inline_content(&spans, &styles, &fonts, 10.0, 0.0, 0.0, None);
assert_eq!(line_texts(&lines), vec!["hello", "world"]);
}
#[test]
fn consecutive_brs_produce_an_empty_line() {
let (_, spans, styles) = spans_for("a<br><br>b", "");
let fonts = dejavu_only();
let lines = layout_inline_content(&spans, &styles, &fonts, 5000.0, 0.0, 0.0, None);
assert_eq!(line_texts(&lines), vec!["a", "", "b"]);
assert!(
lines[1].rect.height > 0.0,
"the blank line must still take vertical space"
);
assert_eq!(lines[1].rect.y, lines[0].rect.y + lines[0].rect.height);
assert_eq!(lines[2].rect.y, lines[1].rect.y + lines[1].rect.height);
}
#[test]
fn a_trailing_br_ends_the_line_without_adding_one() {
let fonts = dejavu_only();
let texts = |html: &str| {
let (_, spans, styles) = spans_for(html, "");
line_texts(&layout_inline_content(
&spans, &styles, &fonts, 5000.0, 0.0, 0.0, None,
))
};
assert_eq!(texts("a<br>"), vec!["a"]);
assert_eq!(texts("a<br><br>"), vec!["a", ""]);
assert_eq!(texts("<br>"), vec![""]);
assert_eq!(texts("<br><br>"), vec!["", ""]);
}
#[test]
fn a_leading_br_pushes_the_text_down_by_one_line() {
let (_, spans, styles) = spans_for("<br>a", "");
let fonts = dejavu_only();
let lines = layout_inline_content(&spans, &styles, &fonts, 5000.0, 0.0, 0.0, None);
assert_eq!(line_texts(&lines), vec!["", "a"]);
assert_eq!(lines[1].rect.y, lines[0].rect.height);
}
#[test]
fn br_does_not_swallow_the_surrounding_words() {
let (_, spans, styles) = spans_for("one two<br>three four", "");
let fonts = dejavu_only();
let lines = layout_inline_content(&spans, &styles, &fonts, 5000.0, 0.0, 0.0, None);
assert_eq!(line_texts(&lines), vec!["one two", "three four"]);
}
#[test]
fn br_inside_pre_also_breaks_the_line() {
let (_, spans, styles) = spans_for("a<br>b", "p { white-space: pre; }");
let fonts = dejavu_only();
let lines = layout_inline_content(&spans, &styles, &fonts, 5000.0, 0.0, 0.0, None);
assert_eq!(line_texts(&lines), vec!["a", "b"]);
}
#[test]
fn the_empty_line_of_a_br_uses_its_own_line_height() {
let (_, spans, styles) = spans_for(
"a<br><br>b",
"p { font-size: 10px; } br { font-size: 40px; line-height: 2; }",
);
let fonts = dejavu_only();
let lines = layout_inline_content(&spans, &styles, &fonts, 5000.0, 0.0, 0.0, None);
assert_eq!(line_texts(&lines), vec!["a", "", "b"]);
assert_eq!(
lines[1].rect.height, 80.0,
"the blank line takes the <br>'s own line-height (40px * 2)"
);
}
#[test]
fn br_clear_pushes_the_next_line_below_a_float() {
use crate::layout::float_ctx::FloatContext;
use crate::style::Float;
let (_, spans, styles) = spans_for("a<br clear=\"left\">b", "");
let fonts = dejavu_only();
let mut ctx = FloatContext::new();
ctx.register(Float::Left, 0.0, 0.0, 50.0, 100.0);
let lines = layout_inline_content(&spans, &styles, &fonts, 500.0, 0.0, 0.0, Some(&ctx));
assert_eq!(line_texts(&lines), vec!["a", "b"]);
assert!(
lines[1].rect.y >= 100.0,
"the line after <br clear=left> must clear the float, got y={}",
lines[1].rect.y
);
}
#[test]
fn a_trailing_br_clear_ends_the_block_at_the_floats_bottom() {
use crate::layout::float_ctx::FloatContext;
use crate::style::Float;
let (_, spans, styles) = spans_for("a<br clear=\"left\">", "");
let fonts = dejavu_only();
let mut ctx = FloatContext::new();
ctx.register(Float::Left, 0.0, 0.0, 50.0, 100.0);
let lines = layout_inline_content(&spans, &styles, &fonts, 500.0, 0.0, 0.0, Some(&ctx));
assert_eq!(line_texts(&lines), vec!["a", ""]);
let last = lines.last().expect("lines");
assert_eq!(last.rect.y + last.rect.height, 100.0);
let (_, spans, styles) = spans_for("a<br clear=\"left\">", "");
let lines = layout_inline_content(&spans, &styles, &fonts, 500.0, 0.0, 0.0, None);
assert_eq!(line_texts(&lines), vec!["a"]);
}
#[test]
fn a_trailing_newline_under_white_space_pre_ends_the_line_without_adding_one() {
let fonts = dejavu_only();
let texts = |html: &str| {
let (_, spans, styles) = spans_for(html, "p { white-space: pre; }");
line_texts(&layout_inline_content(
&spans, &styles, &fonts, 5000.0, 0.0, 0.0, None,
))
};
assert_eq!(texts("a\n"), vec!["a"]);
assert_eq!(texts("a<br>"), vec!["a"]);
assert_eq!(texts("a\n\n"), vec!["a", ""]);
assert_eq!(texts("a\nb"), vec!["a", "b"]);
assert_eq!(texts("<br>"), vec![""]);
}
fn run_shifts(line: &LineBox) -> Vec<(String, f32)> {
line.runs
.iter()
.map(|r| (r.text.clone(), r.baseline_shift))
.collect()
}
#[test]
fn baseline_is_the_default_and_shifts_nothing() {
let (_, spans, styles) = spans_for("plain <span>text</span>", "");
let fonts = dejavu_only();
let lines = layout_inline_content(&spans, &styles, &fonts, 500.0, 0.0, 0.0, None);
assert!(lines[0].runs.iter().all(|r| r.baseline_shift == 0.0));
assert!(lines[0].baseline > 0.0 && lines[0].baseline < lines[0].rect.height);
}
#[test]
fn a_line_without_vertical_align_keeps_its_previous_height_and_baseline() {
let (_, spans, styles) = spans_for("text", "");
let fonts = dejavu_only();
let lines = layout_inline_content(&spans, &styles, &fonts, 500.0, 0.0, 0.0, None);
let run = &lines[0].runs[0];
let font = fonts.get(run.font_index).unwrap();
assert_eq!(lines[0].rect.height, run.line_height);
let expected = font.baseline_offset(run.font_size, run.line_height);
assert!(
(lines[0].baseline - expected).abs() < 0.01,
"baseline {} should match Font::baseline_offset {}",
lines[0].baseline,
expected
);
}
#[test]
fn sup_raises_and_sub_lowers_the_run() {
let (_, spans, styles) = spans_for("H<sub>2</sub>O<sup>3</sup>", "");
let fonts = dejavu_only();
let lines = layout_inline_content(&spans, &styles, &fonts, 500.0, 0.0, 0.0, None);
let shifts = run_shifts(&lines[0]);
let sub = shifts.iter().find(|(t, _)| t == "2").expect("sub run");
let sup = shifts.iter().find(|(t, _)| t == "3").expect("sup run");
assert!(sub.1 < 0.0, "sub should be lowered: {sub:?}");
assert!(sup.1 > 0.0, "super should be raised: {sup:?}");
assert!(shifts.iter().find(|(t, _)| t == "H").unwrap().1 == 0.0);
}
#[test]
fn a_raised_run_grows_the_line_box() {
let fonts = dejavu_only();
let (_, plain_spans, plain_styles) = spans_for("Hx", "");
let plain =
layout_inline_content(&plain_spans, &plain_styles, &fonts, 500.0, 0.0, 0.0, None);
let (_, spans, styles) = spans_for("H<span>x</span>", "span { vertical-align: 30px; }");
let raised = layout_inline_content(&spans, &styles, &fonts, 500.0, 0.0, 0.0, None);
assert!(
raised[0].rect.height > plain[0].rect.height,
"{} should exceed {}",
raised[0].rect.height,
plain[0].rect.height
);
assert!(raised[0].baseline > plain[0].baseline);
}
#[test]
fn length_and_percentage_values_shift_by_the_specified_amount() {
let fonts = dejavu_only();
let (_, spans, styles) = spans_for(
"a<span class=\"px\">b</span><span class=\"pct\">c</span>",
".px { vertical-align: 5px; } .pct { vertical-align: 50%; line-height: 20px; }",
);
let lines = layout_inline_content(&spans, &styles, &fonts, 500.0, 0.0, 0.0, None);
let shifts = run_shifts(&lines[0]);
assert_eq!(shifts.iter().find(|(t, _)| t == "b").unwrap().1, 5.0);
assert_eq!(shifts.iter().find(|(t, _)| t == "c").unwrap().1, 10.0);
}
#[test]
fn negative_length_lowers_the_run() {
let fonts = dejavu_only();
let (_, spans, styles) = spans_for("a<span>b</span>", "span { vertical-align: -4px; }");
let lines = layout_inline_content(&spans, &styles, &fonts, 500.0, 0.0, 0.0, None);
let shifts = run_shifts(&lines[0]);
assert_eq!(shifts.iter().find(|(t, _)| t == "b").unwrap().1, -4.0);
}
#[test]
fn text_top_and_text_bottom_align_with_the_first_run() {
let fonts = dejavu_only();
let (_, spans, styles) = spans_for(
"big<span class=\"t\">t</span><span class=\"b\">b</span>",
"p { font-size: 30px; } .t, .b { font-size: 10px; } \
.t { vertical-align: text-top; } .b { vertical-align: text-bottom; }",
);
let lines = layout_inline_content(&spans, &styles, &fonts, 500.0, 0.0, 0.0, None);
let shifts = run_shifts(&lines[0]);
let small_run = lines[0].runs.iter().find(|r| r.text == "t").unwrap();
let base_run = lines[0].runs.iter().find(|r| r.text == "big").unwrap();
let t_shift = shifts.iter().find(|(t, _)| t == "t").unwrap().1;
assert!((t_shift - (base_run.ascent - small_run.ascent)).abs() < 0.01);
let b_shift = shifts.iter().find(|(t, _)| t == "b").unwrap().1;
assert!(
b_shift < 0.0,
"text-bottom should lower a smaller run: {b_shift}"
);
}
#[test]
fn top_and_bottom_align_with_the_line_box_edges() {
let fonts = dejavu_only();
let (_, spans, styles) = spans_for(
"big<span class=\"t\">t</span><span class=\"b\">b</span>",
"p { font-size: 40px; } .t, .b { font-size: 10px; } \
.t { vertical-align: top; } .b { vertical-align: bottom; }",
);
let lines = layout_inline_content(&spans, &styles, &fonts, 500.0, 0.0, 0.0, None);
let line = &lines[0];
let top_run = line.runs.iter().find(|r| r.text == "t").unwrap();
let bottom_run = line.runs.iter().find(|r| r.text == "b").unwrap();
let top_of_run = line.baseline - top_run.baseline_shift - top_run.ascent;
assert!(top_of_run.abs() < 0.01, "expected 0, got {top_of_run}");
let bottom_of_run = line.baseline - bottom_run.baseline_shift + bottom_run.descent;
assert!(
(bottom_of_run - line.rect.height).abs() < 0.01,
"expected {}, got {bottom_of_run}",
line.rect.height
);
}
#[test]
fn middle_centers_the_run_around_the_x_height() {
let fonts = dejavu_only();
let (_, spans, styles) = spans_for(
"big<span>m</span>",
"p { font-size: 40px; } span { font-size: 10px; vertical-align: middle; }",
);
let lines = layout_inline_content(&spans, &styles, &fonts, 500.0, 0.0, 0.0, None);
let run = lines[0].runs.iter().find(|r| r.text == "m").unwrap();
let base = lines[0].runs.iter().find(|r| r.text == "big").unwrap();
let x_height = fonts.get(base.font_index).unwrap().x_height(base.font_size);
let center_of_run = run.baseline_shift + (run.ascent - run.descent) / 2.0;
assert!(
(center_of_run - x_height / 2.0).abs() < 0.01,
"run center {center_of_run} should sit at half the x-height {}",
x_height / 2.0
);
}
}