use std::collections::HashMap;
use std::rc::Rc;
use crate::fonts::FontCollection;
use crate::html::NodeId;
use crate::style::{
BoxSizing, CaptionSide, ComputedStyle, FlexDirection, LengthPercentage, LengthPercentageOrAuto,
MaxSize, RepeatCount, TableLayout, TrackComponent, TrackList, VerticalAlign,
};
use super::block::resolve_lpa_or_zero;
use super::block::{
apply_replaced_element_auto_size, box_style, clamp_used_width, layout_box,
layout_box_with_forced_width, resolve_border, resolve_lp, resolve_padding, shift_box_y,
shift_box_y_in_place, shift_content_vertical, LaidOutBox, LaidOutContent, LaidOutTable,
LaidOutTableRow, PosCtx,
};
use super::box_tree::{BoxContent, ImageBoxContent, LayoutBox, TableBox, TableCell, TableRow};
use super::float_ctx::FloatContext;
use super::inline::{clear_word_width_cache, layout_inline_content, plain_text_min_content_width};
const UNCONSTRAINED_WIDTH: f32 = f32::MAX / 4.0;
#[allow(clippy::too_many_arguments)]
pub(super) fn layout_table(
table: &TableBox,
styles: &HashMap<NodeId, Rc<ComputedStyle>>,
fonts: &FontCollection,
containing_width: f32,
table_layout: TableLayout,
h_spacing: f32,
v_spacing: f32,
x: f32,
y: f32,
pos: &mut PosCtx,
) -> (LaidOutTable, f32, f32) {
let laid_caption = table.caption.as_deref().map(|caption| {
let mut caption_float_ctx = FloatContext::new();
layout_box(
caption,
styles,
fonts,
containing_width,
&mut caption_float_ctx,
x,
y,
pos,
)
});
let caption_height = laid_caption
.as_ref()
.map(|c| c.layout.margin_box_height())
.unwrap_or(0.0);
let caption_is_top = table.caption_side == CaptionSide::Top;
let rows_block_start = if caption_is_top {
y + caption_height
} else {
y
};
let rows_start_y = rows_block_start + v_spacing;
let (grid, column_count) = build_table_grid(&table.rows);
if column_count == 0 {
let (caption, total_height) = match laid_caption {
Some(c) => (Some(Box::new(c)), caption_height),
None => (None, 0.0),
};
return (
LaidOutTable {
caption,
caption_side: table.caption_side,
rows: Vec::new(),
},
total_height,
containing_width,
);
}
let available_column_width =
(containing_width - h_spacing * (column_count + 1) as f32).max(0.0);
let column_hints: Vec<Option<f32>> = (0..column_count)
.map(|i| {
table
.column_widths
.get(i)
.copied()
.flatten()
.map(|lp| resolve_lp(lp, available_column_width))
})
.collect();
let col_widths = if table_layout == TableLayout::Fixed {
compute_fixed_column_widths(
&grid,
styles,
&column_hints,
column_count,
available_column_width,
)
} else {
compute_column_widths(
&grid,
styles,
fonts,
&column_hints,
column_count,
available_column_width,
)
};
let mut col_x = vec![0.0f32; column_count + 1];
col_x[0] = h_spacing;
for i in 0..column_count {
col_x[i + 1] = col_x[i] + col_widths[i] + h_spacing;
}
let laid_grid: Vec<Vec<LaidOutBox>> = grid
.iter()
.map(|row_cells| {
row_cells
.iter()
.map(|gc| {
let outer_width: f32 = if gc.col_end > gc.col_start {
col_x[gc.col_end] - col_x[gc.col_start] - h_spacing
} else {
0.0
};
let cell_x = x + col_x[gc.col_start];
let cell_style = box_style(&gc.cell.content, styles);
let cell_padding = resolve_padding(&cell_style, outer_width);
let cell_border = resolve_border(&cell_style);
let content_width = (outer_width
- cell_padding.left
- cell_padding.right
- cell_border.left
- cell_border.right)
.max(0.0);
let mut cell_float_ctx = FloatContext::new();
layout_box_with_forced_width(
&gc.cell.content,
styles,
fonts,
outer_width,
content_width,
&mut cell_float_ctx,
cell_x,
0.0,
pos,
)
})
.collect()
})
.collect();
let row_count = table.rows.len();
let mut row_natural = vec![0.0f32; row_count];
for (row_cells, laid_row) in grid.iter().zip(laid_grid.iter()) {
for (gc, laid_cell) in row_cells.iter().zip(laid_row.iter()) {
if gc.cell.rowspan == 1 {
row_natural[gc.row_index] =
row_natural[gc.row_index].max(laid_cell.layout.margin_box_height());
}
}
}
for (row_cells, laid_row) in grid.iter().zip(laid_grid.iter()) {
for (gc, laid_cell) in row_cells.iter().zip(laid_row.iter()) {
if gc.cell.rowspan > 1 {
let end = (gc.row_index + gc.cell.rowspan).min(row_count);
if end > gc.row_index {
let span_count = end - gc.row_index;
let span_natural_sum: f32 = row_natural[gc.row_index..end].iter().sum::<f32>()
+ v_spacing * (span_count - 1) as f32;
let cell_natural = laid_cell.layout.margin_box_height();
if cell_natural > span_natural_sum {
let deficit = cell_natural - span_natural_sum;
let share = deficit / span_count as f32;
for h in &mut row_natural[gc.row_index..end] {
*h += share;
}
}
}
}
}
}
let mut row_y = vec![0.0f32; row_count + 1];
row_y[0] = rows_start_y;
for r in 0..row_count {
row_y[r + 1] = row_y[r] + row_natural[r] + v_spacing;
}
let mut laid_rows = Vec::with_capacity(table.rows.len());
for (row, (row_cells, laid_row)) in table.rows.iter().zip(grid.iter().zip(laid_grid)) {
let mut laid_cells: Vec<LaidOutBox> = row_cells
.iter()
.zip(laid_row)
.map(|(gc, mut laid_cell)| {
shift_box_y_in_place(&mut laid_cell, -row_y[gc.row_index]);
laid_cell
})
.collect();
let row_baseline_offset = laid_cells
.iter()
.zip(row_cells.iter())
.filter(|(cell, _)| cell_vertical_align(cell, styles) == VerticalAlign::Baseline)
.filter_map(|(cell, _)| own_baseline_offset(cell, fonts))
.fold(0.0f32, f32::max);
for (cell, gc) in laid_cells.iter_mut().zip(row_cells.iter()) {
let span_end = (gc.row_index + gc.cell.rowspan).min(row_count);
let final_height = if span_end > gc.row_index {
row_y[span_end] - row_y[gc.row_index] - v_spacing
} else {
0.0
};
let deficit = final_height - cell.layout.margin_box_height();
if deficit <= 0.0 {
continue;
}
cell.layout.content.height += deficit;
let shift_down = match cell_vertical_align(cell, styles) {
VerticalAlign::Top => 0.0,
VerticalAlign::Middle => deficit / 2.0,
VerticalAlign::Bottom => deficit,
VerticalAlign::Baseline
| VerticalAlign::Sub
| VerticalAlign::Super
| VerticalAlign::TextTop
| VerticalAlign::TextBottom
| VerticalAlign::LengthPercentage(_) => match own_baseline_offset(cell, fonts) {
Some(own_offset) => row_baseline_offset - own_offset,
None => deficit,
},
};
if shift_down > 0.0 {
*cell = shift_content_vertical(cell, -shift_down);
}
}
laid_rows.push(LaidOutTableRow {
node: row.node,
cells: laid_cells,
section: row.section,
group: row.group,
});
}
let rows_height = row_y[row_count] - rows_block_start;
let total_height = caption_height + rows_height;
let final_caption = match (laid_caption, caption_is_top) {
(Some(c), true) => Some(Box::new(c)),
(Some(c), false) => {
Some(Box::new(shift_box_y(&c, -rows_height)))
}
(None, _) => None,
};
(
LaidOutTable {
caption: final_caption,
caption_side: table.caption_side,
rows: laid_rows,
},
total_height,
containing_width.max(col_x[column_count]),
)
}
fn cell_vertical_align(
cell: &LaidOutBox,
styles: &HashMap<NodeId, Rc<ComputedStyle>>,
) -> VerticalAlign {
cell.node
.and_then(|n| styles.get(&n))
.map(|s| s.vertical_align)
.unwrap_or_default()
}
fn own_baseline_offset(cell: &LaidOutBox, fonts: &FontCollection) -> Option<f32> {
let absolute = first_baseline_absolute_y(cell, fonts)?;
Some(absolute - cell.layout.content.y)
}
fn first_baseline_absolute_y(b: &LaidOutBox, fonts: &FontCollection) -> Option<f32> {
match &b.content {
LaidOutContent::Inline(lines) => lines.iter().find_map(|line| {
let first_run = line.runs.first()?;
let font = fonts.get(first_run.font_index)?;
let offset = font.baseline_offset(first_run.font_size, line.rect.height);
Some(line.rect.y + offset)
}),
LaidOutContent::Blocks(children) => children
.iter()
.find_map(|child| first_baseline_absolute_y(child, fonts)),
LaidOutContent::Table(_)
| LaidOutContent::Flex(_)
| LaidOutContent::Grid(_)
| LaidOutContent::Image(_) => None,
}
}
struct GridCell<'a> {
cell: &'a TableCell,
row_index: usize,
col_start: usize,
col_end: usize,
}
fn build_table_grid(rows: &[TableRow]) -> (Vec<Vec<GridCell<'_>>>, usize) {
let mut occupied: Vec<usize> = Vec::new();
let mut grid = Vec::with_capacity(rows.len());
for (row_index, row) in rows.iter().enumerate() {
let mut row_cells = Vec::with_capacity(row.cells.len());
let mut col = 0usize;
for cell in &row.cells {
while col < occupied.len() && occupied[col] > 0 {
col += 1;
}
let col_start = col;
let col_end = col_start + cell.colspan;
if occupied.len() < col_end {
occupied.resize(col_end, 0);
}
for slot in &mut occupied[col_start..col_end] {
*slot = cell.rowspan;
}
row_cells.push(GridCell {
cell,
row_index,
col_start,
col_end,
});
col = col_end;
}
grid.push(row_cells);
for slot in &mut occupied {
*slot = slot.saturating_sub(1);
}
}
let column_count = occupied.len();
(grid, column_count)
}
fn compute_fixed_column_widths(
grid: &[Vec<GridCell<'_>>],
styles: &HashMap<NodeId, Rc<ComputedStyle>>,
column_hints: &[Option<f32>],
column_count: usize,
containing_width: f32,
) -> Vec<f32> {
let mut widths: Vec<Option<f32>> = vec![None; column_count];
if let Some(first_row) = grid.first() {
for gc in first_row {
if gc.col_end > gc.col_start {
let cell_style = box_style(&gc.cell.content, styles);
if let Some(resolved) = fixed_cell_width(&cell_style, containing_width) {
let share = resolved / (gc.col_end - gc.col_start) as f32;
for w in &mut widths[gc.col_start..gc.col_end] {
*w = Some(share);
}
}
}
}
}
for (i, hint) in column_hints.iter().enumerate().take(column_count) {
if let Some(hint) = hint {
widths[i] = Some(*hint);
}
}
let specified_sum: f32 = widths.iter().filter_map(|w| *w).sum();
let auto_count = widths.iter().filter(|w| w.is_none()).count();
let remaining = (containing_width - specified_sum).max(0.0);
let auto_share = if auto_count > 0 {
remaining / auto_count as f32
} else {
0.0
};
widths.iter().map(|w| w.unwrap_or(auto_share)).collect()
}
fn compute_column_widths(
grid: &[Vec<GridCell<'_>>],
styles: &HashMap<NodeId, Rc<ComputedStyle>>,
fonts: &FontCollection,
column_hints: &[Option<f32>],
column_count: usize,
containing_width: f32,
) -> Vec<f32> {
let natural = column_widths_by(grid, column_count, |cell| {
natural_cell_width(cell, styles, fonts)
});
let min_content = || {
clear_word_width_cache();
let min_content = column_widths_by(grid, column_count, |cell| {
min_cell_width(cell, styles, fonts)
});
clear_word_width_cache();
min_content
.iter()
.zip(&natural)
.map(|(min, natural)| min.min(*natural))
.collect::<Vec<f32>>()
};
let has_hint = column_hints
.iter()
.take(column_count)
.any(|hint| hint.is_some());
if has_hint {
return distribute_with_column_hints(
&natural,
min_content,
column_hints,
column_count,
containing_width,
);
}
fit_columns(&natural, min_content, containing_width)
}
fn column_widths_by(
grid: &[Vec<GridCell<'_>>],
column_count: usize,
cell_width: impl Fn(&TableCell) -> f32,
) -> Vec<f32> {
let mut widths = vec![0.0f32; column_count];
for row_cells in grid {
for gc in row_cells {
if gc.col_end - gc.col_start == 1 {
widths[gc.col_start] = widths[gc.col_start].max(cell_width(gc.cell));
}
}
}
for row_cells in grid {
for gc in row_cells {
if gc.col_end - gc.col_start > 1 {
let span_sum: f32 = widths[gc.col_start..gc.col_end].iter().sum();
let cell = cell_width(gc.cell);
if cell > span_sum {
let share = (cell - span_sum) / (gc.col_end - gc.col_start) as f32;
for w in &mut widths[gc.col_start..gc.col_end] {
*w += share;
}
}
}
}
}
widths
}
fn fit_columns(natural: &[f32], min_content: impl FnOnce() -> Vec<f32>, target: f32) -> Vec<f32> {
let natural_sum: f32 = natural.iter().sum();
if natural_sum <= 0.0 {
return vec![target / natural.len().max(1) as f32; natural.len()];
}
if natural_sum <= target {
let scale = target / natural_sum;
return natural.iter().map(|w| w * scale).collect();
}
let min_content = min_content();
let min_sum: f32 = min_content.iter().sum();
if min_sum >= target {
return min_content;
}
let keep = (target - min_sum) / (natural_sum - min_sum);
natural
.iter()
.zip(&min_content)
.map(|(n, m)| m + (n - m) * keep)
.collect()
}
fn fixed_cell_width(cell_style: &ComputedStyle, containing_width: f32) -> Option<f32> {
match cell_style.width {
LengthPercentageOrAuto::LengthPercentage(lp) => Some(clamp_used_width(
cell_style,
containing_width,
0.0,
0.0,
resolve_lp(lp, containing_width),
)),
LengthPercentageOrAuto::Auto => {
let min = resolve_lp(cell_style.min_width, containing_width);
(min > 0.0).then_some(min)
}
}
}
fn distribute_with_column_hints(
natural: &[f32],
min_content: impl FnOnce() -> Vec<f32>,
column_hints: &[Option<f32>],
column_count: usize,
containing_width: f32,
) -> Vec<f32> {
let hint_of = |i: usize| column_hints.get(i).copied().flatten();
let hint_sum: f32 = (0..column_count).filter_map(hint_of).sum();
if hint_sum > containing_width && hint_sum > 0.0 {
let scale = containing_width / hint_sum;
return (0..column_count)
.map(|i| hint_of(i).map(|w| w * scale).unwrap_or(0.0))
.collect();
}
let auto_natural_sum: f32 = (0..column_count)
.filter(|&i| hint_of(i).is_none())
.map(|i| natural[i])
.sum();
let remaining = (containing_width - hint_sum).max(0.0);
let auto_cols: Vec<usize> = (0..column_count)
.filter(|&i| hint_of(i).is_none())
.collect();
let shrunk = (auto_natural_sum > remaining).then(|| {
let n: Vec<f32> = auto_cols.iter().map(|&i| natural[i]).collect();
fit_columns(
&n,
|| {
let all = min_content();
auto_cols.iter().map(|&i| all[i]).collect()
},
remaining,
)
});
(0..column_count)
.map(|i| match hint_of(i) {
Some(w) => w,
None if shrunk.is_some() => {
let pos = auto_cols.iter().position(|&c| c == i).unwrap_or(0);
shrunk.as_ref().map_or(0.0, |v| v[pos])
}
None if auto_natural_sum > 0.0 => remaining * natural[i] / auto_natural_sum,
None => {
let auto_count = (0..column_count).filter(|&i| hint_of(i).is_none()).count();
remaining / auto_count as f32
}
})
.collect()
}
fn natural_cell_width(
cell: &TableCell,
styles: &HashMap<NodeId, Rc<ComputedStyle>>,
fonts: &FontCollection,
) -> f32 {
cell_content_width(cell, styles, fonts, Basis::Max)
}
fn min_cell_width(
cell: &TableCell,
styles: &HashMap<NodeId, Rc<ComputedStyle>>,
fonts: &FontCollection,
) -> f32 {
cell_content_width(cell, styles, fonts, Basis::Min)
}
fn cell_content_width(
cell: &TableCell,
styles: &HashMap<NodeId, Rc<ComputedStyle>>,
fonts: &FontCollection,
basis: Basis,
) -> f32 {
let style = box_style(&cell.content, styles);
let padding = resolve_padding(&style, 0.0);
let border = resolve_border(&style);
let content_natural = measure_content_width(&cell.content, styles, fonts, basis);
let clamped = clamp_used_width(
&style,
0.0,
padding.left + padding.right,
border.left + border.right,
content_natural,
);
clamped + padding.left + padding.right + border.left + border.right
}
fn outer_content_width(
child: &LayoutBox,
styles: &HashMap<NodeId, Rc<ComputedStyle>>,
fonts: &FontCollection,
basis: Basis,
) -> f32 {
let style = box_style(child, styles);
let padding = resolve_padding(&style, 0.0);
let border = resolve_border(&style);
let pb_x = padding.left + padding.right + border.left + border.right;
let content = match style.width {
LengthPercentageOrAuto::LengthPercentage(LengthPercentage::Length(px)) => {
if style.box_sizing == BoxSizing::BorderBox {
(px - pb_x).max(0.0)
} else {
px
}
}
_ => measure_content_width(child, styles, fonts, basis),
};
let margin_x =
resolve_lpa_or_zero(style.margin_left, 0.0) + resolve_lpa_or_zero(style.margin_right, 0.0);
content + pb_x + margin_x
}
fn declared_column_count(list: &TrackList) -> usize {
list.components
.iter()
.map(|component| match component {
TrackComponent::Single(_) => 1,
TrackComponent::Repeat { count, tracks, .. } => match count {
RepeatCount::Count(n) => *n as usize * tracks.len(),
RepeatCount::AutoFill | RepeatCount::AutoFit => tracks.len(),
},
})
.sum()
}
pub(super) fn measure_natural_content_width(
b: &LayoutBox,
styles: &HashMap<NodeId, Rc<ComputedStyle>>,
fonts: &FontCollection,
) -> f32 {
if let Some(memo) = b.measured.natural_width() {
return memo;
}
let width = compute_content_width(b, styles, fonts, Basis::Max);
b.measured.set_natural_width(width);
width
}
pub(super) fn measure_min_content_width(
b: &LayoutBox,
styles: &HashMap<NodeId, Rc<ComputedStyle>>,
fonts: &FontCollection,
) -> f32 {
if let Some(memo) = b.measured.min_content_width() {
return memo;
}
let width = compute_content_width(b, styles, fonts, Basis::Min);
b.measured.set_min_content_width(width);
width
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Basis {
Max,
Min,
}
fn measure_content_width(
b: &LayoutBox,
styles: &HashMap<NodeId, Rc<ComputedStyle>>,
fonts: &FontCollection,
basis: Basis,
) -> f32 {
match basis {
Basis::Max => measure_natural_content_width(b, styles, fonts),
Basis::Min => measure_min_content_width(b, styles, fonts),
}
}
fn compute_content_width(
b: &LayoutBox,
styles: &HashMap<NodeId, Rc<ComputedStyle>>,
fonts: &FontCollection,
basis: Basis,
) -> f32 {
match &b.content {
BoxContent::Inline(spans) => {
if basis == Basis::Min {
if let Some(width) = plain_text_min_content_width(spans, styles, fonts, || {
measure_natural_content_width(b, styles, fonts)
}) {
return width;
}
}
let mut discarded = Vec::new();
let mut pos = PosCtx::new(&mut discarded, (0.0, 0.0));
let lines = layout_inline_content(
spans.as_slice(),
styles,
fonts,
if basis == Basis::Min {
0.0
} else {
UNCONSTRAINED_WIDTH
},
0.0,
0.0,
None,
None,
&mut pos,
);
lines.iter().map(|l| l.rect.width).fold(0.0f32, f32::max)
}
BoxContent::Blocks(children) => children
.iter()
.map(|child| outer_content_width(child, styles, fonts, basis))
.fold(0.0f32, f32::max),
BoxContent::Flex(flex) => {
let style = box_style(b, styles);
let items: Vec<f32> = flex
.items
.iter()
.map(|item| outer_content_width(item, styles, fonts, basis))
.collect();
match style.flex_direction {
FlexDirection::Row | FlexDirection::RowReverse => {
let gaps =
resolve_lp(style.column_gap, 0.0) * (items.len().saturating_sub(1)) as f32;
items.iter().sum::<f32>() + gaps
}
FlexDirection::Column | FlexDirection::ColumnReverse => {
items.into_iter().fold(0.0f32, f32::max)
}
}
}
BoxContent::Grid(grid) => {
let style = box_style(b, styles);
let columns = declared_column_count(&style.grid_template_columns).max(1);
let gaps = resolve_lp(style.column_gap, 0.0) * (columns.saturating_sub(1)) as f32;
grid.items
.chunks(columns)
.map(|row| {
row.iter()
.map(|item| outer_content_width(item, styles, fonts, basis))
.sum::<f32>()
+ gaps
})
.fold(0.0f32, f32::max)
}
BoxContent::Table(table) => table
.rows
.iter()
.map(|row| {
row.cells
.iter()
.map(|cell| cell_content_width(cell, styles, fonts, basis))
.sum::<f32>()
})
.fold(0.0f32, f32::max),
BoxContent::Image(image_content) => {
replaced_natural_content_width(&box_style(b, styles), image_content)
}
}
}
fn replaced_natural_content_width(style: &ComputedStyle, image: &ImageBoxContent) -> f32 {
let mut style = style.clone();
let is_definite = |lp: LengthPercentage| matches!(lp, LengthPercentage::Length(_));
if let LengthPercentageOrAuto::LengthPercentage(lp) = style.width {
if !is_definite(lp) {
style.width = LengthPercentageOrAuto::Auto;
}
}
if let MaxSize::LengthPercentage(lp) = style.max_width {
if !is_definite(lp) {
style.max_width = MaxSize::None;
}
}
if !is_definite(style.min_width) {
style.min_width = LengthPercentage::Length(0.0);
}
apply_replaced_element_auto_size(&mut style, image, 0.0);
let padding = resolve_padding(&style, 0.0);
let border = resolve_border(&style);
let padding_lr = padding.left + padding.right;
let border_lr = border.left + border.right;
let LengthPercentageOrAuto::LengthPercentage(LengthPercentage::Length(width)) = style.width
else {
return image
.attr_width
.map(|w| w as f32)
.or_else(|| image.image.as_ref().map(|img| img.width))
.unwrap_or(0.0);
};
let width = if style.box_sizing == BoxSizing::BorderBox {
(width - padding_lr - border_lr).max(0.0)
} else {
width
};
clamp_used_width(&style, 0.0, padding_lr, border_lr, width)
}
#[cfg(test)]
mod tests {
use super::super::block::{layout_document, LaidOutBox};
use super::super::box_tree::{build_box_tree, LayoutBox};
use super::*;
use crate::fonts::Font;
use crate::html::{self, Dom, NodeData};
use crate::style::{compute_styles, parse_stylesheet, user_agent_stylesheet};
const TEST_FONT_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fonts/DejaVuSans.ttf");
fn test_fonts() -> FontCollection {
FontCollection::new(vec![
Font::load(TEST_FONT_PATH).expect("should load bundled test font")
])
}
fn find(dom: &Dom, id: NodeId, tag: &str) -> Option<NodeId> {
if let NodeData::Element { name, .. } = &dom.node(id).data {
if &*name.local == tag {
return Some(id);
}
}
dom.children(id).find_map(|child| find(dom, child, tag))
}
fn find_laid_out(b: &LaidOutBox, target: NodeId) -> Option<&LaidOutBox> {
if b.node == Some(target) {
return Some(b);
}
match &b.content {
super::super::block::LaidOutContent::Blocks(children) => children
.iter()
.find_map(|child| find_laid_out(child, target)),
super::super::block::LaidOutContent::Grid(grid) => grid
.rows
.iter()
.flat_map(|row| &row.items)
.find_map(|item| find_laid_out(item, target)),
super::super::block::LaidOutContent::Table(table) => table
.caption
.as_deref()
.and_then(|caption| find_laid_out(caption, target))
.or_else(|| {
table
.rows
.iter()
.flat_map(|row| &row.cells)
.find_map(|cell| find_laid_out(cell, target))
}),
super::super::block::LaidOutContent::Flex(children) => children
.iter()
.find_map(|child| find_laid_out(child, target)),
super::super::block::LaidOutContent::Inline(_)
| super::super::block::LaidOutContent::Image(_) => None,
}
}
fn layout_table_html(html_src: &str, css: &str, containing_width: f32) -> LaidOutBox {
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 fonts = test_fonts();
let laid = layout_document(&tree, &styles, &fonts, containing_width);
let table_node = find(&dom, dom.document(), "table").expect("table not found");
find_laid_out(&laid, table_node)
.expect("table box not found")
.clone()
}
fn cell_widths(table: &LaidOutBox, row: usize) -> Vec<f32> {
let super::super::block::LaidOutContent::Table(laid_table) = &table.content else {
panic!("expected a laid-out table");
};
laid_table.rows[row]
.cells
.iter()
.map(|c| c.layout.border_box().width)
.collect()
}
fn cell_lefts(table: &LaidOutBox, row: usize) -> Vec<f32> {
let super::super::block::LaidOutContent::Table(laid_table) = &table.content else {
panic!("expected a laid-out table");
};
laid_table.rows[row]
.cells
.iter()
.map(|c| c.layout.border_box().x)
.collect()
}
fn row_tops(table: &LaidOutBox) -> Vec<f32> {
let super::super::block::LaidOutContent::Table(laid_table) = &table.content else {
panic!("expected a laid-out table");
};
laid_table
.rows
.iter()
.map(|row| row.cells[0].layout.border_box().y)
.collect()
}
fn row_cells(table: &LaidOutBox, row: usize) -> &[LaidOutBox] {
let super::super::block::LaidOutContent::Table(laid_table) = &table.content else {
panic!("expected a laid-out table");
};
&laid_table.rows[row].cells
}
fn first_line_y(cell: &LaidOutBox) -> f32 {
let super::super::block::LaidOutContent::Inline(lines) = &cell.content else {
panic!("expected inline content");
};
lines[0].rect.y
}
fn find_nested_table(b: &LaidOutBox) -> Option<&LaidOutTable> {
match &b.content {
super::super::block::LaidOutContent::Table(table) => Some(table),
super::super::block::LaidOutContent::Blocks(children)
| super::super::block::LaidOutContent::Flex(children) => {
children.iter().find_map(find_nested_table)
}
super::super::block::LaidOutContent::Grid(grid) => grid
.rows
.iter()
.flat_map(|row| &row.items)
.find_map(find_nested_table),
super::super::block::LaidOutContent::Inline(_)
| super::super::block::LaidOutContent::Image(_) => None,
}
}
#[test]
fn table_stretches_to_fill_the_containing_width() {
let table = layout_table_html(
"<table><tr><td>a</td><td>bb</td></tr></table>",
"body { margin: 0; }",
700.0,
);
let super::super::block::LaidOutContent::Table(laid_table) = &table.content else {
panic!("expected a laid-out table");
};
let total_width: f32 = laid_table.rows[0]
.cells
.iter()
.map(|c| c.layout.border_box().width)
.sum();
assert!(
(total_width - 700.0).abs() < 0.5,
"table should stretch to fill the containing width, got {total_width}"
);
}
#[test]
fn wider_content_gets_a_proportionally_wider_column() {
let table = layout_table_html(
"<table><tr><td>x</td><td>a much much much longer piece of text</td></tr></table>",
"",
700.0,
);
let widths = cell_widths(&table, 0);
assert!(
widths[1] > widths[0] * 3.0,
"the column with much longer content should be proportionally wider: {widths:?}"
);
}
#[test]
fn equal_content_produces_roughly_equal_columns() {
let table = layout_table_html(
"<table><tr><td>identical</td><td>identical</td></tr></table>",
"",
700.0,
);
let widths = cell_widths(&table, 0);
assert!(
(widths[0] - widths[1]).abs() < 0.5,
"identical content should produce identical column widths: {widths:?}"
);
}
#[test]
fn colspan_cell_widens_the_columns_it_spans() {
let table = layout_table_html(
r#"<table>
<tr><td colspan="2">a much much much longer heading spanning both columns nicely</td><td>z</td></tr>
<tr><td>x</td><td>y</td><td>w</td></tr>
</table>"#,
"",
700.0,
);
let row1_widths = cell_widths(&table, 1);
assert!(
row1_widths[0] + row1_widths[1] > row1_widths[2] * 3.0,
"columns spanned by the wide header should be widened relative to the untouched column: {row1_widths:?}"
);
}
#[test]
fn row_height_is_the_tallest_cell_in_that_row() {
let table = layout_table_html(
r#"<table>
<tr><td style="height: 10px;">a</td><td style="height: 80px;">b</td></tr>
</table>"#,
"",
700.0,
);
let super::super::block::LaidOutContent::Table(laid_table) = &table.content else {
panic!("expected a laid-out table");
};
for cell in &laid_table.rows[0].cells {
assert_eq!(
cell.layout.margin_box_height(),
80.0,
"every cell in the row should occupy the tallest cell's height"
);
}
}
#[test]
fn cells_in_the_same_row_are_placed_side_by_side() {
let table = layout_table_html(
"<table><tr><td>a</td><td>b</td><td>c</td></tr></table>",
"",
700.0,
);
let super::super::block::LaidOutContent::Table(laid_table) = &table.content else {
panic!("expected a laid-out table");
};
let cells = &laid_table.rows[0].cells;
for pair in cells.windows(2) {
assert_eq!(
pair[1].layout.border_box().x,
pair[0].layout.border_box().x + pair[0].layout.border_box().width,
"adjacent cells should touch with no gap"
);
}
}
#[test]
fn empty_table_has_no_rows_and_zero_height() {
let table = layout_table_html("<table></table>", "", 700.0);
let super::super::block::LaidOutContent::Table(laid_table) = &table.content else {
panic!("expected a laid-out table");
};
assert!(laid_table.rows.is_empty());
assert_eq!(table.layout.content.height, 0.0);
}
#[test]
fn table_layout_fixed_uses_first_row_widths_and_ignores_content() {
let table = layout_table_html(
r#"<table style="table-layout: fixed;">
<tr><td style="width: 200px;">a</td><td style="width: 500px;">a much much much longer piece of text</td></tr>
<tr><td>x</td><td>x</td></tr>
</table>"#,
"",
700.0,
);
let widths = cell_widths(&table, 0);
assert!((widths[0] - 200.0).abs() < 0.5, "widths: {widths:?}");
assert!((widths[1] - 500.0).abs() < 0.5, "widths: {widths:?}");
}
#[test]
fn table_layout_fixed_distributes_remaining_width_to_auto_columns() {
let table = layout_table_html(
r#"<table style="table-layout: fixed;">
<tr><td style="width: 100px;">a</td><td>b</td><td>c</td></tr>
</table>"#,
"body { margin: 0; }",
700.0,
);
let widths = cell_widths(&table, 0);
assert!((widths[0] - 100.0).abs() < 0.5, "widths: {widths:?}");
assert!((widths[1] - 300.0).abs() < 0.5, "widths: {widths:?}");
assert!((widths[2] - 300.0).abs() < 0.5, "widths: {widths:?}");
}
#[test]
fn table_layout_fixed_splits_a_colspan_width_evenly_across_spanned_columns() {
let table = layout_table_html(
r#"<table style="table-layout: fixed;">
<tr><td colspan="2" style="width: 400px;">a</td></tr>
</table>"#,
"",
700.0,
);
let widths = cell_widths(&table, 0);
assert!((widths[0] - 400.0).abs() < 0.5, "widths: {widths:?}");
}
#[test]
fn border_spacing_adds_horizontal_gaps_between_and_around_columns() {
let table = layout_table_html(
"<table><tr><td>identical</td><td>identical</td></tr></table>",
"body { margin: 0; } table { border-spacing: 20px 0; }",
700.0,
);
let widths = cell_widths(&table, 0);
let lefts = cell_lefts(&table, 0);
assert!((widths[0] - 320.0).abs() < 0.5, "widths: {widths:?}");
assert!((widths[1] - 320.0).abs() < 0.5, "widths: {widths:?}");
assert!(
(lefts[0] - 20.0).abs() < 0.5,
"the first column should be inset by one spacing unit: {lefts:?}"
);
assert!(
(lefts[1] - 360.0).abs() < 0.5,
"the second column should start after column0 + 2 spacing units: {lefts:?}"
);
}
#[test]
fn border_spacing_adds_vertical_gaps_between_and_around_rows() {
let table = layout_table_html(
r#"<table>
<tr><td style="height: 30px;">a</td></tr>
<tr><td style="height: 30px;">b</td></tr>
</table>"#,
"body { margin: 0; } table { border-spacing: 0 15px; }",
700.0,
);
let tops = row_tops(&table);
assert!(
(tops[0] - 15.0).abs() < 0.5,
"the first row should be inset by one spacing unit: {tops:?}"
);
assert!(
(tops[1] - 60.0).abs() < 0.5,
"the second row should start after row0(30px) + 2 spacing units: {tops:?}"
);
assert!(
(table.layout.content.height - 105.0).abs() < 0.5,
"table height should include leading/trailing spacing: {}",
table.layout.content.height
);
}
#[test]
fn border_collapse_forces_border_spacing_to_zero() {
let table = layout_table_html(
"<table><tr><td>a</td><td>b</td></tr></table>",
"body { margin: 0; } table { border-spacing: 20px; border-collapse: collapse; }",
700.0,
);
let lefts = cell_lefts(&table, 0);
let widths = cell_widths(&table, 0);
assert!(
lefts[0].abs() < 0.5,
"with collapse the first column should touch the table's edge: {lefts:?}"
);
assert!(
(lefts[1] - (lefts[0] + widths[0])).abs() < 0.5,
"with collapse adjacent cells should touch with no gap: lefts={lefts:?} widths={widths:?}"
);
}
#[test]
fn vertical_align_top_keeps_content_flush_with_the_row_top() {
let table = layout_table_html(
r#"<table>
<tr><td style="height: 10px;">a</td><td style="height: 80px;">b</td></tr>
</table>"#,
"body { margin: 0; } td { vertical-align: top; }",
700.0,
);
for cell in row_cells(&table, 0) {
assert_eq!(
first_line_y(cell),
0.0,
"top-aligned content should stay flush with the row top"
);
}
}
#[test]
fn vertical_align_bottom_pushes_the_shorter_cells_content_to_the_bottom() {
let table = layout_table_html(
r#"<table>
<tr><td style="height: 10px;">a</td><td style="height: 80px;">b</td></tr>
</table>"#,
"body { margin: 0; } td { vertical-align: bottom; }",
700.0,
);
let cells = row_cells(&table, 0);
assert!(
first_line_y(&cells[1]).abs() < 0.5,
"the tallest cell defines the row height so its own content shouldn't shift: {}",
first_line_y(&cells[1])
);
assert!(
(first_line_y(&cells[0]) - 70.0).abs() < 0.5,
"the shorter cell's content should be pushed down by the full deficit(80-10=70px): {}",
first_line_y(&cells[0])
);
}
#[test]
fn vertical_align_middle_centers_the_shorter_cells_content() {
let table = layout_table_html(
r#"<table>
<tr><td style="height: 10px;">a</td><td style="height: 80px;">b</td></tr>
</table>"#,
"body { margin: 0; } td { vertical-align: middle; }",
700.0,
);
let cells = row_cells(&table, 0);
assert!(
first_line_y(&cells[1]).abs() < 0.5,
"the tallest cell defines the row height so its own content shouldn't shift: {}",
first_line_y(&cells[1])
);
assert!(
(first_line_y(&cells[0]) - 35.0).abs() < 0.5,
"the shorter cell's content should be pushed down by half the deficit((80-10)/2=35px): {}",
first_line_y(&cells[0])
);
}
#[test]
fn vertical_align_baseline_aligns_first_lines_of_cells_with_different_font_sizes() {
let table = layout_table_html(
r#"<table>
<tr><td style="font-size: 12px;">Ay</td><td style="font-size: 36px;">Ay</td></tr>
</table>"#,
"body { margin: 0; } td { vertical-align: baseline; }",
700.0,
);
let fonts = test_fonts();
let cells = row_cells(&table, 0);
let baseline_y = |cell: &LaidOutBox| {
let super::super::block::LaidOutContent::Inline(lines) = &cell.content else {
panic!("expected inline content");
};
let run = lines[0].runs.first().expect("cell should have text");
let font = fonts.get(run.font_index).expect("font should be loaded");
lines[0].rect.y + font.baseline_offset(run.font_size, lines[0].rect.height)
};
let small_baseline = baseline_y(&cells[0]);
let large_baseline = baseline_y(&cells[1]);
assert!(
(small_baseline - large_baseline).abs() < 0.5,
"baseline-aligned cells should share the same baseline Y: small={small_baseline} large={large_baseline}"
);
}
#[test]
fn vertical_align_baseline_falls_back_to_bottom_for_content_without_a_baseline() {
let table = layout_table_html(
r#"<table>
<tr>
<td style="height: 80px;">a</td>
<td style="height: 10px;"><table><tr><td>nested</td></tr></table></td>
</tr>
</table>"#,
"body { margin: 0; } td { vertical-align: baseline; }",
700.0,
);
let cells = row_cells(&table, 0);
let nested_top_y = find_nested_table(&cells[1])
.expect("expected the outer cell to contain a nested table")
.rows[0]
.cells[0]
.layout
.border_box()
.y;
assert!(
(nested_top_y - 70.0).abs() < 0.5,
"content without a baseline should fall back to bottom alignment (deficit=80-10=70px): {nested_top_y}"
);
}
fn grid_cell(colspan: usize, rowspan: usize) -> TableCell {
TableCell {
node: Some(NodeId(0)),
colspan,
rowspan,
content: LayoutBox::anonymous(BoxContent::Inline(Vec::new())),
}
}
fn grid_row(cells: Vec<TableCell>) -> TableRow {
TableRow {
node: Some(NodeId(0)),
cells,
section: super::super::box_tree::TableSection::Body,
group: None,
}
}
fn grid_spans(grid: &[Vec<GridCell<'_>>]) -> Vec<Vec<(usize, usize)>> {
grid.iter()
.map(|row| row.iter().map(|gc| (gc.col_start, gc.col_end)).collect())
.collect()
}
#[test]
fn build_table_grid_matches_the_naive_colspan_walk_when_rowspan_is_always_one() {
let rows = vec![
grid_row(vec![grid_cell(2, 1), grid_cell(1, 1)]),
grid_row(vec![grid_cell(1, 1), grid_cell(1, 1), grid_cell(1, 1)]),
];
let (grid, column_count) = build_table_grid(&rows);
assert_eq!(column_count, 3);
assert_eq!(
grid_spans(&grid),
vec![vec![(0, 2), (2, 3)], vec![(0, 1), (1, 2), (2, 3)]]
);
}
#[test]
fn build_table_grid_skips_columns_occupied_by_a_rowspan_cell() {
let rows = vec![
grid_row(vec![grid_cell(1, 2), grid_cell(1, 1)]),
grid_row(vec![grid_cell(1, 1)]),
grid_row(vec![grid_cell(1, 1), grid_cell(1, 1)]),
];
let (grid, column_count) = build_table_grid(&rows);
assert_eq!(column_count, 2);
assert_eq!(
grid_spans(&grid),
vec![vec![(0, 1), (1, 2)], vec![(1, 2)], vec![(0, 1), (1, 2)]]
);
}
#[test]
fn build_table_grid_handles_rowspan_and_colspan_combined() {
let rows = vec![
grid_row(vec![grid_cell(2, 2), grid_cell(1, 1)]),
grid_row(vec![grid_cell(1, 1)]),
];
let (grid, column_count) = build_table_grid(&rows);
assert_eq!(column_count, 3);
assert_eq!(grid_spans(&grid), vec![vec![(0, 2), (2, 3)], vec![(2, 3)]]);
}
#[test]
fn build_table_grid_counts_a_column_that_only_exists_after_a_rowspan_is_skipped() {
let rows = vec![
grid_row(vec![grid_cell(1, 2)]),
grid_row(vec![grid_cell(1, 1)]),
];
let (grid, column_count) = build_table_grid(&rows);
assert_eq!(
column_count, 2,
"the column opened up next to the rowspan cell must be counted"
);
assert_eq!(grid_spans(&grid), vec![vec![(0, 1)], vec![(1, 2)]]);
}
#[test]
fn rowspan_cell_spans_the_full_height_of_the_rows_it_covers() {
let table = layout_table_html(
r#"<table>
<tr><td rowspan="2" style="height: 80px;">tall</td><td style="height: 10px;">a</td></tr>
<tr><td style="height: 10px;">b</td></tr>
</table>"#,
"body { margin: 0; }",
700.0,
);
let row0 = row_cells(&table, 0);
let row1 = row_cells(&table, 1);
assert_eq!(row1.len(), 1, "row1 should only have its own single cell");
assert!(
(row0[0].layout.margin_box_height() - 80.0).abs() < 0.5,
"the rowspan cell should span exactly the combined height of both rows: {}",
row0[0].layout.margin_box_height()
);
assert!(
(row0[1].layout.margin_box_height() - 40.0).abs() < 0.5,
"row0's non-spanning cell should be stretched to row0's height(40px): {}",
row0[1].layout.margin_box_height()
);
assert!(
(row1[0].layout.border_box().y - 40.0).abs() < 0.5,
"row1 should start after row0's height(40px): {}",
row1[0].layout.border_box().y
);
}
#[test]
fn rowspan_cell_makes_the_following_row_skip_its_occupied_column() {
let table = layout_table_html(
r#"<table>
<tr><td rowspan="2">tall</td><td>a</td></tr>
<tr><td>b</td></tr>
</table>"#,
"body { margin: 0; }",
700.0,
);
let row0_lefts = cell_lefts(&table, 0);
let row1 = row_cells(&table, 1);
assert!(
(row1[0].layout.border_box().x - row0_lefts[1]).abs() < 0.5,
"row1's single cell should land in column1 (same x as row0's second cell), not column0: row1_x={} row0_col1_x={}",
row1[0].layout.border_box().x,
row0_lefts[1]
);
}
#[test]
fn col_width_fixes_the_column_width_in_auto_layout() {
let table = layout_table_html(
r#"<table>
<colgroup><col style="width: 100px;"><col></colgroup>
<tr><td>a</td><td>bbbbbbbbbbbbbbbb</td></tr>
</table>"#,
"body { margin: 0; } table { border-spacing: 0; }",
500.0,
);
let widths = cell_widths(&table, 0);
assert!((widths[0] - 100.0).abs() < 0.5, "got {widths:?}");
assert!((widths[1] - 400.0).abs() < 0.5, "got {widths:?}");
}
#[test]
fn col_percentage_width_resolves_against_the_table_width() {
let table = layout_table_html(
r#"<table>
<colgroup><col style="width: 20%;"><col></colgroup>
<tr><td>a</td><td>b</td></tr>
</table>"#,
"body { margin: 0; } table { border-spacing: 0; }",
500.0,
);
let widths = cell_widths(&table, 0);
assert!((widths[0] - 100.0).abs() < 0.5, "got {widths:?}");
}
#[test]
fn col_span_applies_the_same_width_to_several_columns() {
let table = layout_table_html(
r#"<table>
<colgroup><col span="2" style="width: 50px;"><col></colgroup>
<tr><td>a</td><td>b</td><td>c</td></tr>
</table>"#,
"body { margin: 0; } table { border-spacing: 0; }",
500.0,
);
let widths = cell_widths(&table, 0);
assert!((widths[0] - 50.0).abs() < 0.5, "got {widths:?}");
assert!((widths[1] - 50.0).abs() < 0.5, "got {widths:?}");
assert!((widths[2] - 400.0).abs() < 0.5, "got {widths:?}");
}
#[test]
fn colgroup_span_without_col_children_defines_the_columns_itself() {
let table = layout_table_html(
r#"<table>
<colgroup span="2" style="width: 60px;"></colgroup>
<tr><td>a</td><td>b</td><td>c</td></tr>
</table>"#,
"body { margin: 0; } table { border-spacing: 0; }",
500.0,
);
let widths = cell_widths(&table, 0);
assert!((widths[0] - 60.0).abs() < 0.5, "got {widths:?}");
assert!((widths[1] - 60.0).abs() < 0.5, "got {widths:?}");
assert!((widths[2] - 380.0).abs() < 0.5, "got {widths:?}");
}
#[test]
fn columns_without_a_col_hint_share_the_rest_proportionally_to_their_content() {
let table = layout_table_html(
r#"<table>
<colgroup><col style="width: 100px;"><col><col></colgroup>
<tr><td>a</td><td>short</td><td>a much much much longer cell</td></tr>
</table>"#,
"body { margin: 0; } table { border-spacing: 0; }",
500.0,
);
let widths = cell_widths(&table, 0);
assert!((widths[0] - 100.0).abs() < 0.5, "got {widths:?}");
assert!(
widths[2] > widths[1],
"the column with more content should get more of the remaining width: {widths:?}"
);
let total: f32 = widths.iter().sum();
assert!((total - 500.0).abs() < 1.0, "got {widths:?}");
}
#[test]
fn col_hints_wider_than_the_table_are_scaled_down_to_fit() {
let table = layout_table_html(
r#"<table>
<colgroup><col style="width: 600px;"><col style="width: 200px;"></colgroup>
<tr><td>a</td><td>b</td></tr>
</table>"#,
"body { margin: 0; } table { border-spacing: 0; }",
400.0,
);
let widths = cell_widths(&table, 0);
assert!((widths[0] - 300.0).abs() < 0.5, "got {widths:?}");
assert!((widths[1] - 100.0).abs() < 0.5, "got {widths:?}");
}
#[test]
fn col_width_takes_precedence_over_the_first_row_cell_in_fixed_layout() {
let table = layout_table_html(
r#"<table>
<colgroup><col style="width: 300px;"><col></colgroup>
<tr><td style="width: 100px;">a</td><td>b</td></tr>
</table>"#,
"body { margin: 0; } table { table-layout: fixed; border-spacing: 0; }",
500.0,
);
let widths = cell_widths(&table, 0);
assert!(
(widths[0] - 300.0).abs() < 0.5,
"the <col> width must win over the first row cell: {widths:?}"
);
assert!((widths[1] - 200.0).abs() < 0.5, "got {widths:?}");
}
#[test]
fn a_table_without_colgroup_keeps_the_previous_behaviour() {
let table = layout_table_html(
r#"<table><tr><td>a</td><td>a much much much longer cell</td></tr></table>"#,
"body { margin: 0; } table { border-spacing: 0; }",
500.0,
);
let widths = cell_widths(&table, 0);
let total: f32 = widths.iter().sum();
assert!((total - 500.0).abs() < 1.0, "got {widths:?}");
assert!(widths[1] > widths[0], "got {widths:?}");
}
fn widest_line(b: &LaidOutBox) -> f32 {
match &b.content {
super::super::block::LaidOutContent::Inline(lines) => {
lines.iter().map(|l| l.rect.width).fold(0.0, f32::max)
}
super::super::block::LaidOutContent::Blocks(children) => {
children.iter().map(widest_line).fold(0.0, f32::max)
}
_ => 0.0,
}
}
fn assert_no_cell_overflows(table: &LaidOutBox, padding: f32) {
let super::super::block::LaidOutContent::Table(laid_table) = &table.content else {
panic!("expected a laid-out table");
};
for row in &laid_table.rows {
for cell in &row.cells {
let content = cell.layout.border_box().width - 2.0 * padding;
let line = widest_line(cell);
assert!(
line <= content + 0.01,
"text of {line} overflows a cell content box of {content}"
);
}
}
}
const SHRINK_HTML: &str = "<table><tr><th>ITEM</th><th>PRICE</th><th>TOTAL</th></tr>\
<tr><td>Refund August overpayment caused by understated sibling deposit</td>\
<td>$300.00</td><td>$3,480.00</td></tr></table>";
const SHRINK_CSS: &str = "body { margin: 0; } td, th { padding: 4px; }";
#[test]
fn shrinking_takes_the_space_from_the_wrappable_column() {
let table = layout_table_html(SHRINK_HTML, SHRINK_CSS, 300.0);
assert_no_cell_overflows(&table, 4.0);
let widths = cell_widths(&table, 1);
assert!(
(widths.iter().sum::<f32>() - 300.0).abs() < 0.5,
"got {widths:?}"
);
}
#[test]
fn a_column_of_unbreakable_text_keeps_its_min_content_width_at_any_table_width() {
for containing in [260.0, 300.0, 400.0, 600.0] {
let table = layout_table_html(SHRINK_HTML, SHRINK_CSS, containing);
assert_no_cell_overflows(&table, 4.0);
}
}
#[test]
fn columns_overflow_instead_of_overlapping_when_min_content_exceeds_the_table() {
let table = layout_table_html(SHRINK_HTML, SHRINK_CSS, 60.0);
assert_no_cell_overflows(&table, 4.0);
let lefts = cell_lefts(&table, 1);
let widths = cell_widths(&table, 1);
for i in 1..lefts.len() {
assert!(
lefts[i] >= lefts[i - 1] + widths[i - 1] - 0.01,
"columns overlap: lefts {lefts:?}, widths {widths:?}"
);
}
}
#[test]
fn fit_columns_shrinks_only_the_slack_above_min_content() {
let w = fit_columns(&[100.0, 40.0], || vec![20.0, 40.0], 110.0);
assert!(
(w[0] - 70.0).abs() < 0.01 && (w[1] - 40.0).abs() < 0.01,
"{w:?}"
);
let w = fit_columns(&[100.0, 50.0], || vec![40.0, 30.0], 120.0);
assert!((w.iter().sum::<f32>() - 120.0).abs() < 0.01, "{w:?}");
assert!(w[0] >= 40.0 && w[1] >= 30.0, "{w:?}");
}
#[test]
fn fit_columns_stretches_proportionally_and_never_goes_below_min_content() {
let w = fit_columns(&[10.0, 30.0], || vec![5.0, 30.0], 80.0);
assert!(
(w[0] - 20.0).abs() < 0.01 && (w[1] - 60.0).abs() < 0.01,
"{w:?}"
);
let w = fit_columns(&[100.0, 40.0], || vec![20.0, 40.0], 50.0);
assert_eq!(w, vec![20.0, 40.0]);
}
#[test]
fn unhinted_columns_keep_min_content_next_to_a_col_width_hint() {
let table = layout_table_html(
"<table><colgroup><col style=\"width: 130px\"><col><col></colgroup>\
<tr><td>Refund August overpayment caused by understated sibling deposit</td>\
<td>$300.00</td><td>$3,480.00</td></tr></table>",
SHRINK_CSS,
300.0,
);
assert_no_cell_overflows(&table, 4.0);
let widths = cell_widths(&table, 0);
assert!((widths[0] - 130.0).abs() < 0.5, "got {widths:?}");
}
fn slow_min_content(b: &LayoutBox, styles: &HashMap<NodeId, Rc<ComputedStyle>>) -> f32 {
let BoxContent::Inline(spans) = &b.content else {
panic!("expected inline content");
};
let mut discarded = Vec::new();
let mut pos = PosCtx::new(&mut discarded, (0.0, 0.0));
layout_inline_content(
spans.as_slice(),
styles,
&test_fonts(),
0.0,
0.0,
0.0,
None,
None,
&mut pos,
)
.iter()
.map(|l| l.rect.width)
.fold(0.0f32, f32::max)
}
#[test]
fn plain_text_min_content_matches_a_zero_width_line_layout() {
clear_word_width_cache();
let texts = [
"$3,480.00",
"Refund August overpayment caused by understated sibling deposit",
" leading and trailing ",
"a-very-long-hyphenated-word is here",
"W i i i",
];
for css in [
"",
"td { letter-spacing: 2px; font-size: 20px; }",
"td { letter-spacing: -1px; }",
"td { hyphens: auto; }",
"td { hyphens: none; }",
"td { text-indent: 20px; }",
] {
for text in texts {
let dom =
html::parse(format!("<table><tr><td>{text}</td></tr></table>").as_bytes());
let ua = user_agent_stylesheet();
let styles = compute_styles(&dom, &ua, &parse_stylesheet(css));
let tree = build_box_tree(&dom, &styles);
fn first_cell(b: &LayoutBox) -> Option<&LayoutBox> {
match &b.content {
BoxContent::Table(t) => t.rows.first()?.cells.first().map(|c| &c.content),
BoxContent::Blocks(children) => children.iter().find_map(first_cell),
_ => None,
}
}
let cell = first_cell(&tree).expect("cell");
let BoxContent::Inline(spans) = &cell.content else {
panic!("expected inline content");
};
let fast = plain_text_min_content_width(spans, &styles, &test_fonts(), || {
measure_natural_content_width(cell, &styles, &test_fonts())
});
let slow = slow_min_content(cell, &styles);
match fast {
Some(fast) => assert!(
(fast - slow).abs() < 0.01,
"{text:?} with {css:?}: fast {fast} vs layout {slow}"
),
None => assert!(css.contains("text-indent"), "{text:?} with {css:?}"),
}
}
}
}
const NOWRAP_ROW: &str = r#"<table><tr><td class="item">Refund August overpayment caused by an understated sibling deposit, final</td><td>$3,480.00</td></tr></table>"#;
#[test]
fn nowrap_cell_keeps_its_line_and_the_table_box_grows_with_the_columns() {
let css = "body { margin: 0; } table { width: 540px; border-spacing: 0; } .item { white-space: nowrap; }";
let table = layout_table_html(NOWRAP_ROW, css, 800.0);
let widths = cell_widths(&table, 0);
let total: f32 = widths.iter().sum();
assert!(total > 540.0, "columns must overflow 540px, got {widths:?}");
assert!(
(table.layout.content.width - total).abs() < 0.5,
"the table box must grow with its columns: box {} vs columns {total}",
table.layout.content.width
);
}
#[test]
fn table_box_keeps_its_specified_width_when_the_columns_fit() {
let css = "body { margin: 0; } table { width: 800px; border-spacing: 0; } .item { white-space: nowrap; }";
let table = layout_table_html(NOWRAP_ROW, css, 800.0);
assert!((table.layout.content.width - 800.0).abs() < 0.5);
}
fn laid_out_width_of(html_src: &str, css: &str, containing_width: f32, tag: &str) -> f32 {
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 fonts = test_fonts();
let laid = layout_document(&tree, &styles, &fonts, containing_width);
let node = find(&dom, dom.document(), tag).expect("element not found");
find_laid_out(&laid, node)
.expect("box not found")
.layout
.border_box()
.width
}
#[test]
fn float_with_nowrap_text_does_not_shrink_below_the_line() {
let css = "body { margin: 0; } .w { width: 60px; } .f { float: left; }";
let nowrap = laid_out_width_of(
r#"<div class="w"><p class="f" style="white-space: nowrap">aaaa bbbb cccc dddd</p></div>"#,
css,
800.0,
"p",
);
let wide = laid_out_width_of(
r#"<div><p class="f" style="white-space: nowrap">aaaa bbbb cccc dddd</p></div>"#,
css,
800.0,
"p",
);
assert!((nowrap - wide).abs() < 0.5, "{nowrap} vs {wide}");
assert!(nowrap > 60.0);
}
#[test]
fn float_with_wrappable_text_still_shrinks_to_the_available_width() {
let css = "body { margin: 0; } .w { width: 100px; } .f { float: left; }";
let w = laid_out_width_of(
r#"<div class="w"><p class="f">aaaa bbbb cccc dddd eeee ffff</p></div>"#,
css,
800.0,
"p",
);
assert!((w - 100.0).abs() < 0.5, "got {w}");
}
}