Improve authored tables and SVG intrinsic sizing

This commit is contained in:
SGavrl committed 2026-08-04 04:05:00 +00:00
1 parent b4d11b4c9e
commit 0562fb4921
6 files changed
+970 -210

No files matched your search

+482 -149
View File
@@ -2521,9 +2521,18 @@ pub fn layout_dom_with_resources(
italic: None,
})
.collect();
layout_dom_with_web_fonts(tree, viewport, intrinsic, &fonts)
let intrinsic = intrinsic
.iter()
.filter_map(|(&nid, &(width, height))| {
(width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0)
.then(|| (nid, crate::ReplacedIntrinsic::from_dimensions(width, height)))
})
.collect();
layout_dom_with_web_fonts(tree, viewport, &intrinsic, &fonts)
}
type ReplacedIntrinsicMap = HashMap<NodeId, crate::ReplacedIntrinsic>;
const CONTAINER_LAYOUT_SAFETY_LIMIT: usize = 512;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -3326,7 +3335,7 @@ fn retained_style_plan(
pub(crate) fn layout_dom_with_web_fonts(
tree: &DomTree,
viewport: (f32, f32),
intrinsic: &HashMap<NodeId, (f32, f32)>,
intrinsic: &ReplacedIntrinsicMap,
fonts: &[crate::inline::WebFont],
) -> DomLayout {
layout_dom_with_web_fonts_measured(tree, viewport, intrinsic, fonts).0
@@ -3336,7 +3345,7 @@ pub(crate) fn layout_dom_with_web_fonts(
pub(crate) fn layout_dom_with_web_fonts_and_stylesheet_cache(
tree: &DomTree,
viewport: (f32, f32),
intrinsic: &HashMap<NodeId, (f32, f32)>,
intrinsic: &ReplacedIntrinsicMap,
fonts: &[crate::inline::WebFont],
stylesheet_cache: &mut crate::css::StylesheetCache,
) -> DomLayout {
@@ -3353,7 +3362,7 @@ pub(crate) fn layout_dom_with_web_fonts_and_stylesheet_cache(
pub(crate) fn layout_dom_with_web_fonts_and_stylesheet_cache_at_animation_time(
tree: &DomTree,
viewport: (f32, f32),
intrinsic: &HashMap<NodeId, (f32, f32)>,
intrinsic: &ReplacedIntrinsicMap,
fonts: &[crate::inline::WebFont],
stylesheet_cache: &mut crate::css::StylesheetCache,
animation_sample_time: crate::AnimationSampleTime,
@@ -3376,7 +3385,7 @@ pub(crate) fn layout_dom_with_web_fonts_and_stylesheet_cache_at_animation_time(
pub(crate) fn layout_dom_with_web_fonts_and_stylesheet_cache_with_animation_state(
tree: &DomTree,
viewport: (f32, f32),
intrinsic: &HashMap<NodeId, (f32, f32)>,
intrinsic: &ReplacedIntrinsicMap,
fonts: &[crate::inline::WebFont],
stylesheet_cache: &mut crate::css::StylesheetCache,
animation_sample: crate::AnimationSample,
@@ -3401,7 +3410,7 @@ pub(crate) fn layout_dom_with_web_fonts_and_stylesheet_cache_with_animation_stat
pub(crate) fn layout_dom_with_web_fonts_and_retained_styles(
tree: &DomTree,
viewport: (f32, f32),
intrinsic: &HashMap<NodeId, (f32, f32)>,
intrinsic: &ReplacedIntrinsicMap,
fonts: &[crate::inline::WebFont],
stylesheet_cache: &mut crate::css::StylesheetCache,
retained: RetainedStyleMaps,
@@ -3422,7 +3431,7 @@ pub(crate) fn layout_dom_with_web_fonts_and_retained_styles(
pub(crate) fn layout_dom_with_web_fonts_and_retained_styles_at_animation_time(
tree: &DomTree,
viewport: (f32, f32),
intrinsic: &HashMap<NodeId, (f32, f32)>,
intrinsic: &ReplacedIntrinsicMap,
fonts: &[crate::inline::WebFont],
stylesheet_cache: &mut crate::css::StylesheetCache,
retained: RetainedStyleMaps,
@@ -3449,7 +3458,7 @@ pub(crate) fn layout_dom_with_web_fonts_and_retained_styles_at_animation_time(
pub(crate) fn layout_dom_with_web_fonts_and_retained_styles_with_animation_state(
tree: &DomTree,
viewport: (f32, f32),
intrinsic: &HashMap<NodeId, (f32, f32)>,
intrinsic: &ReplacedIntrinsicMap,
fonts: &[crate::inline::WebFont],
stylesheet_cache: &mut crate::css::StylesheetCache,
retained: RetainedStyleMaps,
@@ -3475,7 +3484,7 @@ pub(crate) fn layout_dom_with_web_fonts_and_retained_styles_with_animation_state
fn layout_dom_with_web_fonts_measured(
tree: &DomTree,
viewport: (f32, f32),
intrinsic: &HashMap<NodeId, (f32, f32)>,
intrinsic: &ReplacedIntrinsicMap,
fonts: &[crate::inline::WebFont],
) -> (DomLayout, ContainerLayoutTelemetry) {
layout_dom_with_web_fonts_pass_limit(tree, viewport, intrinsic, fonts, None, None, None, &[])
@@ -3484,7 +3493,7 @@ fn layout_dom_with_web_fonts_measured(
fn layout_dom_with_web_fonts_pass_limit(
tree: &DomTree,
viewport: (f32, f32),
intrinsic: &HashMap<NodeId, (f32, f32)>,
intrinsic: &ReplacedIntrinsicMap,
fonts: &[crate::inline::WebFont],
pass_limit: Option<usize>,
stylesheet_cache: Option<&mut crate::css::StylesheetCache>,
@@ -3509,7 +3518,7 @@ fn layout_dom_with_web_fonts_pass_limit(
fn layout_dom_with_web_fonts_pass_limit_at_animation_time(
tree: &DomTree,
viewport: (f32, f32),
intrinsic: &HashMap<NodeId, (f32, f32)>,
intrinsic: &ReplacedIntrinsicMap,
fonts: &[crate::inline::WebFont],
pass_limit: Option<usize>,
stylesheet_cache: Option<&mut crate::css::StylesheetCache>,
@@ -3782,7 +3791,7 @@ fn layout_dom_with_web_fonts_pass_limit_at_animation_time(
fn layout_dom_once(
tree: &DomTree,
viewport: (f32, f32),
intrinsic: &HashMap<NodeId, (f32, f32)>,
intrinsic: &ReplacedIntrinsicMap,
fonts: &[crate::inline::WebFont],
sheet: &crate::css::Stylesheet,
snapshot: Option<&crate::css::ContainerSnapshot>,
@@ -3890,6 +3899,7 @@ fn layout_dom_once(
is_inline_block: bool,
flow_root: bool,
is_table_box: bool,
is_table_cell_box: bool,
color: Option<[u8; 4]>,
font_size: Option<f32>,
font_weight: u16,
@@ -3940,6 +3950,7 @@ fn layout_dom_once(
is_inline_block: false,
flow_root: false,
is_table_box: false,
is_table_cell_box: false,
color: None,
font_size: None,
font_weight: 400,
@@ -4039,6 +4050,7 @@ fn layout_dom_once(
inh.is_inline_block = style.is_inline_block;
inh.flow_root = style.flow_root;
inh.is_table_box = style.is_table_box;
inh.is_table_cell_box = style.is_table_cell_box;
inh.color = style.color.or(inh.color);
inh.font_size = style.font_size.or(inh.font_size);
inh.font_weight = crate::style::used_font_weight(style);
@@ -4205,9 +4217,14 @@ fn layout_dom_once(
style.is_inline_block = inh.is_inline_block;
style.flow_root = inh.flow_root;
style.is_table_box = inh.is_table_box;
// `internal_flex_container` describes an engine-only UA
// approximation, not the computed CSS display value.
style.internal_flex_container = false;
style.is_table_cell_box = inh.is_table_cell_box;
// Reconstruct the internal cell-content wrapper only when
// the inherited computed display is table-cell.
style.internal_flex_container = style.is_table_cell_box;
if style.is_table_cell_box {
style.flex_direction = Some(taffy::FlexDirection::Column);
style.align_items = Some(taffy::AlignItems::FLEX_START);
}
style.display_inherit = false;
}
inh.display = style.display;
@@ -4215,6 +4232,7 @@ fn layout_dom_once(
inh.is_inline_block = style.is_inline_block;
inh.flow_root = style.flow_root;
inh.is_table_box = style.is_table_box;
inh.is_table_cell_box = style.is_table_cell_box;
match style.color {
Some(c) => inh.color = Some(c),
None => style.color = inh.color,
@@ -4647,6 +4665,7 @@ fn layout_dom_once(
let host_is_inline_block = style.is_inline_block;
let host_flow_root = style.flow_root;
let host_is_table_box = style.is_table_box;
let host_is_table_cell_box = style.is_table_cell_box;
let host_grid_auto_columns = style.grid_auto_columns.clone();
let host_grid_auto_rows = style.grid_auto_rows.clone();
let host_grid_auto_column_calcs = style.grid_calc_expressions[2].clone();
@@ -4682,7 +4701,12 @@ fn layout_dom_once(
pseudo.is_inline_block = host_is_inline_block;
pseudo.flow_root = host_flow_root;
pseudo.is_table_box = host_is_table_box;
pseudo.internal_flex_container = false;
pseudo.is_table_cell_box = host_is_table_cell_box;
pseudo.internal_flex_container = pseudo.is_table_cell_box;
if pseudo.is_table_cell_box {
pseudo.flex_direction = Some(taffy::FlexDirection::Column);
pseudo.align_items = Some(taffy::AlignItems::FLEX_START);
}
pseudo.display_inherit = false;
}
if pseudo.overflow_inherit_x {
@@ -5261,14 +5285,17 @@ fn layout_dom_once(
// 0x0 and never paints); with one dimension given, the aspect ratio
// fills the other. An authored `max-width:100%` still caps it to the
// container, and the aspect ratio keeps it proportional.
for (&nid, &(iw, ih)) in intrinsic {
if iw <= 0.0 || ih <= 0.0 {
for (&nid, &metadata) in intrinsic {
if metadata.natural_size().is_none() {
continue;
}
if let Some(s) = styles.get_mut(&nid) {
s.intrinsic_size = Some((iw, ih));
if s.aspect_ratio.is_none() || s.aspect_ratio_is_mapped {
s.aspect_ratio = Some(iw / ih);
s.intrinsic_size = metadata.natural_size();
s.replaced_intrinsic = Some(metadata);
if (s.aspect_ratio.is_none() || s.aspect_ratio_is_mapped)
&& metadata.ratio.is_some()
{
s.aspect_ratio = metadata.ratio;
s.aspect_ratio_is_mapped = false;
s.aspect_ratio_is_intrinsic = true;
}
@@ -5352,12 +5379,8 @@ fn layout_dom_once(
// preferring the author's specified width when there is one.
let mut tables: Vec<(taffy::NodeId, NodeId, usize)> = id_map
.iter()
.filter(|(_, dom)| {
tree.get_node(**dom)
.and_then(|n| n.as_element().map(|e| e.local.as_ref() == "table"))
.unwrap_or(false)
})
.map(|(t, d)| (*t, *d, table_ancestor_depth(tree, *d)))
.filter(|(taffy, _)| ifc_items.table_rows.contains_key(taffy))
.map(|(t, d)| (*t, *d, table_ancestor_depth(tree, *d, &styles)))
.collect();
// Outer tables consume their nested tables' intrinsic sizes.
// Only after an outer depth is fixed can a root layout expose
@@ -5610,87 +5633,35 @@ fn layout_dom_once(
// contribution, not a min-content floor. Preserve the
// content minimum and raise only the preferred width so
// a constrained table can interpolate below the hint.
// Percentage tracks retain the existing definite-table
// behavior here; broader percent balancing is separate.
let specified_columns = ifc_items.table_cols.get(&tnode);
if let Some((spec_px, spec_pct)) = specified_columns {
for j in 0..ncols {
let fixed = spec_px.get(j).copied().flatten();
if let Some(w) = fixed {
let w = w.max(col_min[j]);
col_max[j] = w;
} else if let Some(w) = spec_pct
.get(j)
let fixed: Vec<Option<f32>> = (0..ncols)
.map(|index| {
specified_columns
.and_then(|(px, _)| px.get(index))
.copied()
.flatten()
.map(|percent| percent * target)
{
let w = w.max(col_min[j]);
col_min[j] = w;
col_max[j] = w;
}
})
.collect();
let percentages: Vec<Option<f32>> = (0..ncols)
.map(|index| {
specified_columns
.and_then(|(_, pct)| pct.get(index))
.copied()
.flatten()
})
.collect();
for j in 0..ncols {
if let Some(width) = fixed[j] {
col_max[j] = width.max(col_min[j]);
}
}
let sum_min: f32 = col_min.iter().sum();
let sum_max: f32 = col_max.iter().sum();
let widths: Vec<f32> = if target <= sum_min {
col_min.clone()
} else if target >= sum_max {
// Surplus follows the table-layout priority: auto
// columns absorb it before fixed and percentage
// columns. This is important even when every
// column has min==max (a one-word auto cell still
// fills the remainder of a definite-width table).
let mut result = col_max.clone();
let extra = target - sum_max;
let is_px = |j: usize| {
specified_columns
.and_then(|(px, _)| px.get(j))
.copied()
.flatten()
.is_some()
};
let is_pct = |j: usize| {
specified_columns
.and_then(|(_, pct)| pct.get(j))
.copied()
.flatten()
.is_some()
};
let mut candidates: Vec<usize> = (0..ncols)
.filter(|&j| !is_px(j) && !is_pct(j) && col_max[j] > 0.0)
.collect();
if candidates.is_empty() {
candidates =
(0..ncols).filter(|&j| !is_px(j) && !is_pct(j)).collect();
}
if candidates.is_empty() {
candidates = (0..ncols).filter(|&j| is_px(j)).collect();
}
if candidates.is_empty() {
candidates = (0..ncols).filter(|&j| is_pct(j)).collect();
}
if candidates.is_empty() {
candidates = (0..ncols).collect();
}
let weight_sum: f32 = candidates.iter().map(|&j| col_max[j]).sum();
for &j in &candidates {
let share = if weight_sum > 0.0 {
extra * col_max[j] / weight_sum
} else {
extra / candidates.len() as f32
};
result[j] += share;
}
result
} else {
let scale = (target - sum_min) / (sum_max - sum_min);
col_min
.iter()
.zip(&col_max)
.map(|(mn, mx)| mn + (mx - mn) * scale)
.collect()
};
let widths = distribute_auto_table_columns(
target,
&col_min,
&col_max,
&fixed,
&percentages,
);
if let Ok(cur) = taffy_tree.style(tnode) {
let mut s = cur.clone();
s.size.width = length(used_declaration);
@@ -9010,14 +8981,15 @@ fn synthesize_row_rects(tree: &DomTree, rects: &mut HashMap<NodeId, Rect>) {
/// Number of ancestor tables containing `id`. Table sizing runs outer-first:
/// an outer table consumes an inner table's intrinsic contributions, then a
/// root layout establishes the inner table's real cell containing block.
fn table_ancestor_depth(tree: &DomTree, id: NodeId) -> usize {
fn table_ancestor_depth(
tree: &DomTree,
id: NodeId,
styles: &HashMap<NodeId, crate::LayoutStyle>,
) -> usize {
let mut depth = 0usize;
let mut cur = id;
while let Some(p) = tree.get_node(cur).and_then(|n| n.parent) {
if tree.get_node(p).is_some_and(|node| {
node.as_element()
.is_some_and(|element| element.local.as_ref() == "table")
}) {
if styles.get(&p).is_some_and(|style| style.is_table_box) {
depth += 1;
}
cur = p;
@@ -9378,6 +9350,163 @@ fn table_inline_outer_edges(style: &crate::LayoutStyle) -> f32 {
+ spacing * 2.0
}
/// Resolve auto-table column constraints into final track widths.
///
/// Gecko's auto-table strategy uses four monotonic guesses: all columns at
/// min-content; percentage columns raised toward their percentage; fixed
/// columns raised toward their preferred lengths; then auto columns raised to
/// max-content. The used width interpolates only the category between the two
/// surrounding guesses. This matters for Bootstrap input groups: a 100% input
/// column must shrink by the intrinsic widths of adjacent `width:1%; nowrap`
/// addon/button columns instead of making the table overflow.
fn distribute_auto_table_columns(
target: f32,
minimums: &[f32],
preferreds: &[f32],
fixed: &[Option<f32>],
percentages: &[Option<f32>],
) -> Vec<f32> {
let count = minimums.len();
if count == 0
|| preferreds.len() != count
|| fixed.len() != count
|| percentages.len() != count
{
return minimums.to_vec();
}
let target = target.max(0.0);
let mins: Vec<f32> = minimums.iter().map(|value| value.max(0.0)).collect();
let prefs: Vec<f32> = preferreds
.iter()
.zip(&mins)
.map(|(preferred, minimum)| preferred.max(*minimum))
.collect();
// Percentage column constraints are cumulatively clamped to 100% in
// source order. Keep zero-valued entries as percentage columns: their
// intrinsic minimum still participates in the percentage guess.
let mut remaining = 1.0f32;
let effective_percentages: Vec<Option<f32>> = percentages
.iter()
.map(|percentage| {
percentage.map(|value| {
let used = value.max(0.0).min(remaining);
remaining = (remaining - used).max(0.0);
used
})
})
.collect();
let guess_min = mins.clone();
let guess_min_pct: Vec<f32> = (0..count)
.map(|index| {
effective_percentages[index]
.map(|percentage| (percentage * target).max(mins[index]))
.unwrap_or(mins[index])
})
.collect();
let guess_min_spec: Vec<f32> = (0..count)
.map(|index| {
if effective_percentages[index].is_some() {
guess_min_pct[index]
} else if fixed[index].is_some() {
prefs[index]
} else {
mins[index]
}
})
.collect();
let guess_pref: Vec<f32> = (0..count)
.map(|index| {
if effective_percentages[index].is_some() {
guess_min_pct[index]
} else {
prefs[index]
}
})
.collect();
let total = |values: &[f32]| values.iter().sum::<f32>();
let min_total = total(&guess_min);
let min_pct_total = total(&guess_min_pct);
let min_spec_total = total(&guess_min_spec);
let pref_total = total(&guess_pref);
let interpolate = |lower: &[f32], upper: &[f32], wanted: f32| {
let lower_total = total(lower);
let delta_total = (total(upper) - lower_total).max(0.0);
if delta_total <= f32::EPSILON {
return lower.to_vec();
}
let scale = ((wanted - lower_total) / delta_total).clamp(0.0, 1.0);
lower
.iter()
.zip(upper)
.map(|(low, high)| low + (high - low).max(0.0) * scale)
.collect()
};
if target <= min_total {
return guess_min;
}
if target < min_pct_total {
return interpolate(&guess_min, &guess_min_pct, target);
}
if target < min_spec_total {
return interpolate(&guess_min_pct, &guess_min_spec, target);
}
if target < pref_total {
return interpolate(&guess_min_spec, &guess_pref, target);
}
let mut result = guess_pref;
let extra = target - pref_total;
if extra <= f32::EPSILON {
return result;
}
let mut candidates: Vec<(usize, f32)> = (0..count)
.filter(|index| effective_percentages[*index].is_none() && fixed[*index].is_none())
.filter_map(|index| (prefs[index] > 0.0).then_some((index, prefs[index])))
.collect();
if candidates.is_empty() {
candidates = (0..count)
.filter(|index| {
effective_percentages[*index].is_none() && fixed[*index].is_none()
})
.map(|index| (index, 1.0))
.collect();
}
if candidates.is_empty() {
candidates = (0..count)
.filter(|index| effective_percentages[*index].is_none() && fixed[*index].is_some())
.map(|index| (index, prefs[index].max(0.0)))
.collect();
}
if candidates.is_empty() {
candidates = effective_percentages
.iter()
.enumerate()
.filter_map(|(index, percentage)| {
(*percentage)
.filter(|value| *value > 0.0)
.map(|value| (index, value))
})
.collect();
}
if candidates.is_empty() {
candidates = (0..count).map(|index| (index, 1.0)).collect();
}
let weight: f32 = candidates.iter().map(|(_, value)| *value).sum();
let candidate_count = candidates.len() as f32;
for (index, value) in candidates {
result[index] += if weight > 0.0 {
extra * value / weight
} else {
extra / candidate_count
};
}
result
}
/// Build a `<table>` as a CSS grid. Modeling the table as a grid is what makes
/// columns negotiate a shared width across every row (min-content/max-content
/// track sizing), which the old flex-row-per-`<tr>` stack could not do: each
@@ -9400,10 +9529,50 @@ fn build_table(
styles: &HashMap<NodeId, crate::LayoutStyle>,
) -> Option<taffy::NodeId> {
let style = styles.get(&id)?;
let native_html_table = tree.get_node(id).is_some_and(|node| {
node.as_element()
.is_some_and(|element| element.local.as_ref() == "table")
});
let authored_row_children = if native_html_table {
None
} else {
let children = rendered_children(tree, id);
let mut flattened = Vec::new();
flatten_contents_children(tree, &children, styles, &mut flattened);
// Full anonymous-table fixup is not represented yet. Falling back to
// ordinary box construction preserves every child; partially taking
// the dedicated path would silently discard direct text/non-cell
// boxes once one real table cell made the build succeed.
let all_rendered_children_are_cells = flattened.iter().all(|child| {
let hidden = styles
.get(child)
.is_some_and(|child_style| child_style.display == crate::Display::None);
let ignorable_whitespace = tree.get_node(*child).is_some_and(|node| {
!node.is_element() && tree.text_content(*child).trim().is_empty()
});
hidden
|| ignorable_whitespace
|| styles
.get(child)
.is_some_and(|child_style| child_style.is_table_cell_box)
});
if !all_rendered_children_are_cells {
return None;
}
Some(flattened)
};
let mut rows: Vec<(NodeId, usize)> = Vec::new();
collect_table_rows(tree, id, &mut rows);
if rows.is_empty() {
return None;
if native_html_table {
collect_table_rows(tree, id, &mut rows);
if rows.is_empty() {
return None;
}
} else {
// CSS table fixup inserts an anonymous row around table-cell children
// that are direct children of a table. The grid representation does
// not need a material row node, but retaining one logical row gives
// those cells the same shared-track negotiation as native cells.
rows.push((id, 1));
}
// Bounds so a crafted table cannot exhaust memory or time: `colspan`/
// `rowspan` and the column count are page-controlled, and the occupancy
@@ -9434,11 +9603,23 @@ fn build_table(
let mut ncols = 0usize;
for (r, &(tr, group_end)) in rows.iter().enumerate() {
let mut c = 0usize;
for cid in tree.children(tr) {
let row_children = if native_html_table {
tree.children(tr)
} else {
authored_row_children.clone().unwrap_or_default()
};
for cid in row_children {
let local = tree
.get_node(cid)
.and_then(|n| n.as_element().map(|e| e.local.to_string()));
if !matches!(local.as_deref(), Some("td") | Some("th")) {
let is_cell = if native_html_table {
matches!(local.as_deref(), Some("td") | Some("th"))
} else {
styles
.get(&cid)
.is_some_and(|cell| cell.is_table_cell_box)
};
if !is_cell {
continue;
}
// A hidden cell is removed from the table model entirely (it must
@@ -9456,10 +9637,18 @@ fn build_table(
if c >= MAX_COLS {
break;
}
let cs = span_attr(cid, "colspan").clamp(1, MAX_SPAN);
let cs = if native_html_table {
span_attr(cid, "colspan").clamp(1, MAX_SPAN)
} else {
1
};
// rowspan=0 means "span to the end of this row group". Explicit
// spans are clipped at the same boundary by the effective cell map.
let rs_raw = span_attr(cid, "rowspan");
let rs_raw = if native_html_table {
span_attr(cid, "rowspan")
} else {
1
};
let rows_left_in_group = group_end.min(nrows).saturating_sub(r).max(1);
let rs = if rs_raw == 0 {
rows_left_in_group
@@ -9507,6 +9696,18 @@ fn build_table(
// grid area. Left in place, a `width:50%` cell would shrink to
// half of its own already-halved track.
cstyle.size.width = Dimension::auto();
// Taffy's grid-item automatic minimum is an engine artifact here:
// the table track has already collected the cell's min-content
// contribution and owns final column sizing. Remove only the
// initial `auto` minimum at translation time; an authored
// min-width remains a real constraint and computed style stays
// declaration-order independent.
if styles
.get(cid)
.is_some_and(|cell_style| cell_style.min_width == crate::Dimension::Auto)
{
cstyle.min_size.width = taffy::Dimension::length(0.0);
}
let _ = taffy_tree.set_style(cell_node, cstyle);
}
children.push(cell_node);
@@ -9618,10 +9819,12 @@ fn build_table(
// Row sizing: a `height` on the row or a rowspan-1 cell is a MINIMUM
// (content can always grow a row), matching how tables treat heights.
let mut row_min: Vec<Option<f32>> = vec![None; nrows];
for (r, &(tr, _)) in rows.iter().enumerate() {
if let Some(crate::Dimension::Px(h)) = styles.get(&tr).map(|s| s.height) {
if h > 0.0 {
row_min[r] = Some(h);
if native_html_table {
for (r, &(tr, _)) in rows.iter().enumerate() {
if let Some(crate::Dimension::Px(h)) = styles.get(&tr).map(|s| s.height) {
if h > 0.0 {
row_min[r] = Some(h);
}
}
}
}
@@ -10218,11 +10421,11 @@ fn build(
return None;
}
// Real table layout: a <table> becomes a CSS grid so columns negotiate
// Real table layout: every computed table box becomes a CSS grid so columns negotiate
// across rows (the flex approximation could not) and colspan/rowspan map to
// grid spans. Falls through to the generic path only if the table has no
// usable rows/cells.
if _name.local.as_ref() == "table" {
if style.is_table_box {
if let Some(node) = build_table(
tree, id, taffy_tree, id_map, words, engine, ifc_items, styles,
) {
@@ -10387,13 +10590,13 @@ fn build(
// max-content measurement; once the percentage axis becomes definite, the
// measure callback derives the other axis through the intrinsic ratio.
if _name.local.as_ref() == "img" {
if let Some((width, height)) = style.intrinsic_size {
let intrinsic_ratio =
if width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0 {
width / height
} else {
1.0
};
if let Some(intrinsic) = style.replaced_intrinsic.or_else(|| {
style
.intrinsic_size
.map(|(width, height)| crate::ReplacedIntrinsic::from_dimensions(width, height))
}) {
let (width, height) = intrinsic.natural_size().unwrap_or((300.0, 150.0));
let intrinsic_ratio = intrinsic.ratio.unwrap_or(2.0);
let preferred_ratio = style
.aspect_ratio
.filter(|ratio| ratio.is_finite() && *ratio > 0.0)
@@ -10455,9 +10658,13 @@ fn build(
.iter()
.flatten()
.any(|expression| expression.contains('%'));
let ratio_only = intrinsic.width.is_none()
&& intrinsic.height.is_none()
&& intrinsic.ratio.is_some();
if matches!(style.width, crate::Dimension::Auto)
&& matches!(style.height, crate::Dimension::Auto)
&& !has_percentage_constraint
&& !ratio_only
&& !is_in_flow_grid_item(tree, id, style, styles)
{
let constrained =
@@ -10494,7 +10701,7 @@ fn build(
if measured_axis_constraint {
taffy_style.aspect_ratio = None;
}
let context = engine.register_replaced(width, height, style);
let context = engine.register_replaced_intrinsic(intrinsic, style);
let leaf = taffy_tree
.new_leaf_with_context(taffy_style, context)
.ok()?;
@@ -10657,9 +10864,7 @@ fn build(
let has_in_flow_block_child = dom_children
.iter()
.any(|cid| styles.get(cid).map_or(false, is_in_flow_block_level));
let is_native_table_cell = node.as_element().map_or(false, |element| {
matches!(element.local.as_ref(), "td" | "th") && style.internal_flex_container
});
let is_internal_table_cell = style.is_table_cell_box && style.internal_flex_container;
// `text-align` affects inline content, never the used width or placement
// of an in-flow block child. `to_taffy_style` promotes a centered/right
@@ -10685,7 +10890,8 @@ fn build(
// block children (e.g. a `position:relative` hero wrapper whose only
// child is out-of-flow) to width 0 and let text and blocks share lines.
// Floats still take the legacy zone path below.
if (style.display == crate::Display::Block || (is_native_table_cell && has_in_flow_block_child))
if (style.display == crate::Display::Block
|| (is_internal_table_cell && has_in_flow_block_child))
&& has_inline_ish_content
&& !has_float_child
{
@@ -11683,22 +11889,25 @@ fn max_definite_table_content_width(
) -> Option<f32> {
let mut best: Option<f32> = None;
for descendant in tree.descendants(id) {
let structural = tree.get_node(descendant).is_some_and(|node| {
node.as_element().is_some_and(|element| {
matches!(
element.local.as_ref(),
"caption"
| "col"
| "colgroup"
| "thead"
| "tbody"
| "tfoot"
| "tr"
| "td"
| "th"
)
})
});
let structural = styles
.get(&descendant)
.is_some_and(|style| style.is_table_cell_box)
|| tree.get_node(descendant).is_some_and(|node| {
node.as_element().is_some_and(|element| {
matches!(
element.local.as_ref(),
"caption"
| "col"
| "colgroup"
| "thead"
| "tbody"
| "tfoot"
| "tr"
| "td"
| "th"
)
})
});
if structural {
continue;
}
@@ -12438,6 +12647,14 @@ fn build_children_with_float_zone(
flex_direction: taffy::FlexDirection::Row,
flex_wrap: taffy::FlexWrap::Wrap,
align_items: Some(taffy::AlignItems::FLEX_START),
// This anonymous row represents the float band's available
// inline size. Make that size definite before flex line
// collection: leaving it auto lets intrinsic sizing wrap a set
// of percentage floats before the parent later stretches the row.
size: taffy::Size {
width: taffy::Dimension::percent(1.0),
height: taffy::Dimension::auto(),
},
..Default::default()
};
if let Ok(row) = taffy_tree.new_with_children(row_style, &run_children) {
@@ -13381,6 +13598,42 @@ mod tests {
);
}
#[test]
fn consecutive_percentage_floats_collect_against_the_full_band_width() {
let tree = parse_html(
r#"<style>
html,body{margin:0}
#container{box-sizing:border-box;width:780px}
.cell{float:left;box-sizing:border-box;width:25%;padding:0 15px}
#a{height:87px} #b{height:85px} #c{height:150px} #d{height:76px}
</style>
<div id="container">
<div id="a" class="cell"></div>
<div id="b" class="cell"></div>
<div id="c" class="cell"></div>
<div id="d" class="cell"></div>
</div>"#,
);
let laid = layout_dom(&tree, (900.0, 300.0));
for (index, id) in ["a", "b", "c", "d"].into_iter().enumerate() {
let rect = laid.rects[&tree.get_element_by_id(id).unwrap()];
assert!((rect.width - 195.0).abs() < 0.01, "{id}: {rect:?}");
assert!(
rect.y.abs() < 0.01,
"four 25% floats must share the 780px band; {id} wrapped: {rect:?}"
);
assert!(
(rect.x - index as f32 * 195.0).abs() < 0.01,
"{id}: {rect:?}"
);
}
let container = laid.rects[&tree.get_element_by_id("container").unwrap()];
assert!(
(container.height - 150.0).abs() < 0.01,
"one float band must be as tall as its tallest float: {container:?}"
);
}
#[test]
fn ineligible_nearest_inline_container_does_not_fall_through() {
let tree = parse_html(
@@ -15782,6 +16035,86 @@ mod tests {
assert_eq!(width("border-cell"), 100.0);
}
#[test]
fn authored_table_with_mixed_direct_content_preserves_non_cell_child() {
let tree = parse_html(
r#"<style>
html,body { margin:0 }
#group { display:table; width:300px }
#cell { display:table-cell; width:100px; height:20px }
#ordinary { display:block; width:80px; height:30px }
</style>
<div id="group">
<div id="cell"></div>
direct text
<div id="ordinary"></div>
</div>"#,
);
let laid = layout_dom(&tree, (500.0, 200.0));
let id = |name: &str| tree.get_element_by_id(name).unwrap();
assert!(laid.rects.contains_key(&id("group")));
assert!(laid.rects.contains_key(&id("cell")));
let ordinary = laid.rects[&id("ordinary")];
assert!((ordinary.width - 80.0).abs() < 0.1, "{ordinary:?}");
assert!((ordinary.height - 30.0).abs() < 0.1, "{ordinary:?}");
// Before the preflight check, finding `cell` was enough to take the
// dedicated table path, which filtered `ordinary` (and the text) out.
// Its geometry proves the mixed subtree instead used ordinary box
// construction and kept the non-cell sibling.
}
#[cfg(feature = "paint")]
#[test]
fn authored_table_cells_allocate_bootstrap_input_group_columns() {
let tree = parse_html(
r#"<style>
* { box-sizing:border-box }
html,body { margin:0 }
#group { display:table; width:600px; border-collapse:separate }
#field,#addon,#button { display:table-cell; height:34px }
#field { width:100% }
#addon,#button {
width:1%; white-space:nowrap; padding:0 12px;
font:16px/normal "Liberation Sans";
}
</style>
<div id="group"><div id="field"></div><div id="addon">Addon label</div><div id="button">Button</div></div>"#,
);
let laid = layout_dom(&tree, (800.0, 200.0));
let rect = |id: &str| laid.rects[&tree.get_element_by_id(id).unwrap()];
let group = rect("group");
let field = rect("field");
let addon = rect("addon");
let button = rect("button");
assert!((group.width - 600.0).abs() < 0.1, "{group:?}");
assert!((field.x - group.x).abs() < 0.1, "{field:?} {group:?}");
assert!((addon.x - (field.x + field.width)).abs() < 0.1);
assert!((button.x - (addon.x + addon.width)).abs() < 0.1);
assert!(((button.x + button.width) - (group.x + group.width)).abs() < 0.1);
assert!(addon.width > 80.0, "nowrap addon must keep its intrinsic width: {addon:?}");
assert!(button.width > 50.0, "nowrap button must keep its intrinsic width: {button:?}");
assert!(field.width > addon.width + button.width, "{field:?} {addon:?} {button:?}");
for cell in [field, addon, button] {
assert!((cell.height - 34.0).abs() < 0.1, "{cell:?}");
}
}
#[test]
fn auto_table_percent_guess_reserves_intrinsic_neighbor_columns() {
let widths = distribute_auto_table_columns(
600.0,
&[0.0, 100.0, 70.0],
&[0.0, 100.0, 70.0],
&[None, None, None],
&[Some(1.0), Some(0.01), Some(0.01)],
);
assert!((widths[0] - 430.0).abs() < 0.01, "{widths:?}");
assert!((widths[1] - 100.0).abs() < 0.01, "{widths:?}");
assert!((widths[2] - 70.0).abs() < 0.01, "{widths:?}");
}
#[test]
fn white_space_inherits_into_a_block_descendant_inline_context() {
let tree = parse_html(
+111 -19
View File
@@ -732,7 +732,8 @@ const REPLACED_CONTEXT_BIT: usize = 1usize << (usize::BITS - 1);
#[derive(Clone, Copy)]
struct ReplacedItem {
intrinsic_width: f32,
intrinsic_width: Option<f32>,
intrinsic_height: Option<f32>,
preferred_width: Option<f32>,
preferred_height: Option<f32>,
preferred_ratio: f32,
@@ -740,6 +741,10 @@ struct ReplacedItem {
min_height: Option<f32>,
max_width: Option<f32>,
max_height: Option<f32>,
/// Both intrinsic axes are absent but a real preferred ratio exists.
/// CSS replaced sizing stretch-fits this case to a definite available
/// inline size instead of using the 300x150 default-object contribution.
ratio_only: bool,
/// CSS Sizing's cyclic-percentage rule makes a proper replaced element's
/// inline min-content contribution zero when its preferred or maximum
/// inline size contains a percentage. The natural size still participates
@@ -749,6 +754,10 @@ struct ReplacedItem {
impl ReplacedItem {
fn from_style(width: f32, height: f32, style: &LayoutStyle) -> Self {
Self::from_intrinsic(crate::ReplacedIntrinsic::from_dimensions(width, height), style)
}
fn from_intrinsic(intrinsic: crate::ReplacedIntrinsic, style: &LayoutStyle) -> Self {
let px = |dimension| match dimension {
Dimension::Px(value) => Some(value.max(0.0)),
_ => None,
@@ -758,24 +767,40 @@ impl ReplacedItem {
.as_deref()
.map_or(false, |expression| expression.contains('%'))
};
let intrinsic_ratio =
if width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0 {
width / height
} else {
1.0
};
let explicit_ratio = style
.aspect_ratio
.filter(|ratio| ratio.is_finite() && *ratio > 0.0)
.or_else(|| {
intrinsic
.ratio
.filter(|ratio| ratio.is_finite() && *ratio > 0.0)
});
let intrinsic_ratio = intrinsic
.ratio
.filter(|ratio| ratio.is_finite() && *ratio > 0.0)
.or_else(|| {
let (width, height) = intrinsic.natural_size()?;
(width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0)
.then_some(width / height)
})
.unwrap_or(2.0);
ReplacedItem {
intrinsic_width: width,
intrinsic_width: intrinsic
.width
.filter(|width| width.is_finite() && *width > 0.0),
intrinsic_height: intrinsic
.height
.filter(|height| height.is_finite() && *height > 0.0),
preferred_width: px(style.width),
preferred_height: px(style.height),
preferred_ratio: style
.aspect_ratio
.filter(|ratio| ratio.is_finite() && *ratio > 0.0)
.unwrap_or(intrinsic_ratio),
preferred_ratio: explicit_ratio.unwrap_or(intrinsic_ratio),
min_width: px(style.min_width),
min_height: px(style.min_height),
max_width: px(style.max_width),
max_height: px(style.max_height),
ratio_only: intrinsic.width.is_none()
&& intrinsic.height.is_none()
&& explicit_ratio.is_some(),
zero_inline_min_content: matches!(style.width, Dimension::Percent(_))
|| matches!(style.max_width, Dimension::Percent(_))
|| expression_has_percentage(0)
@@ -846,10 +871,22 @@ impl ReplacedItem {
(Some(width), Some(height)) => (width, height),
(Some(width), None) => (width, width / self.preferred_ratio),
(None, Some(height)) => (height * self.preferred_ratio, height),
(None, None) => (
self.intrinsic_width,
self.intrinsic_width / self.preferred_ratio,
),
(None, None) => match (self.intrinsic_width, self.intrinsic_height) {
(Some(width), Some(height)) => (width, height),
(Some(width), None) => (width, width / self.preferred_ratio),
(None, Some(height)) => (height * self.preferred_ratio, height),
// Contain the intrinsic ratio inside CSS Images' 300x150
// default object size. A definite authored/known axis is
// handled above and transfers through the same ratio.
(None, None) => {
let width = if self.preferred_ratio >= 2.0 {
300.0
} else {
150.0 * self.preferred_ratio
};
(width, width / self.preferred_ratio)
}
},
},
};
let tentative = taffy::Size { width, height };
@@ -1472,9 +1509,20 @@ impl TextEngine {
/// context. Percentage-sized image leaves still need their intrinsic
/// max-content contribution while an auto-sized ancestor is measured.
pub fn register_replaced(&mut self, width: f32, height: f32, style: &LayoutStyle) -> usize {
self.register_replaced_intrinsic(
crate::ReplacedIntrinsic::from_dimensions(width, height),
style,
)
}
pub(crate) fn register_replaced_intrinsic(
&mut self,
intrinsic: crate::ReplacedIntrinsic,
style: &LayoutStyle,
) -> usize {
let index = self.replaced.len();
self.replaced
.push(ReplacedItem::from_style(width, height, style));
.push(ReplacedItem::from_intrinsic(intrinsic, style));
REPLACED_CONTEXT_BIT | index
}
@@ -1489,7 +1537,19 @@ impl TextEngine {
) -> taffy::Size<f32> {
if idx & REPLACED_CONTEXT_BIT != 0 {
let replaced = self.replaced[idx & !REPLACED_CONTEXT_BIT];
let mut size = replaced.size(known);
let stretch_width = (replaced.ratio_only
&& replaced.preferred_width.is_none()
&& replaced.preferred_height.is_none()
&& known.width.is_none())
.then(|| match available.width {
taffy::AvailableSpace::Definite(width) => Some(width.max(0.0)),
_ => None,
})
.flatten();
let mut size = replaced.size(taffy::Size {
width: known.width.or(stretch_width),
height: known.height,
});
if replaced.zero_inline_min_content
&& known.width.is_none()
&& matches!(available.width, taffy::AvailableSpace::MinContent)
@@ -3714,6 +3774,36 @@ mod tests {
}
}
#[test]
fn partial_replaced_intrinsics_use_the_default_object_axis() {
let unknown = taffy::Size {
width: None,
height: None,
};
let style = LayoutStyle::default();
let width_only = ReplacedItem::from_intrinsic(
crate::ReplacedIntrinsic {
width: Some(100.0),
height: None,
ratio: None,
},
&style,
)
.size(unknown);
assert_eq!((width_only.width, width_only.height), (100.0, 150.0));
let height_only = ReplacedItem::from_intrinsic(
crate::ReplacedIntrinsic {
width: None,
height: Some(100.0),
ratio: None,
},
&style,
)
.size(unknown);
assert_eq!((height_only.width, height_only.height), (300.0, 100.0));
}
#[test]
fn both_auto_replaced_constraints_transfer_through_preferred_ratio() {
let unknown = taffy::Size {
@@ -3721,7 +3811,9 @@ mod tests {
height: None,
};
let measure =
|style: &LayoutStyle| ReplacedItem::from_style(512.0, 323.0, style).size(unknown);
|style: &LayoutStyle| {
ReplacedItem::from_style(512.0, 323.0, style).size(unknown)
};
let max_height = LayoutStyle {
max_height: Dimension::Px(128.0),
+54
View File
@@ -763,6 +763,12 @@ pub struct LayoutStyle {
/// features such as container-query axis availability that depend on the
/// computed display rather than our internal layout approximation.
pub(crate) is_table_box: bool,
/// The computed inner display is `table-cell`.
///
/// Authored internal table boxes need the same cell sizing path as native
/// `<td>`/`<th>` elements even though Taffy represents their cell-content
/// wrapper as an internal column flexbox.
pub(crate) is_table_cell_box: bool,
/// The HTML UA sheet's vendor `text-align` behavior for `<center>`.
/// Unlike ordinary `text-align:center`, it also centers fixed-width block
/// descendants while leaving auto-width blocks fill-available.
@@ -807,6 +813,10 @@ pub struct LayoutStyle {
/// size when percentage dimensions are resolved through an auto-sized
/// wrapper (`img { width:100%; height:auto }`).
pub intrinsic_size: Option<(f32, f32)>,
/// Per-axis decoded intrinsic metadata used by the replaced sizing path.
/// SVG can expose only one dimension or a `viewBox` ratio, distinctions
/// that the stable public `intrinsic_size` tuple cannot represent.
pub(crate) replaced_intrinsic: Option<ReplacedIntrinsic>,
/// The current ratio came from HTML width/height presentation hints
/// (`<img>` or its selected `<picture><source>`), rather than authored
/// CSS. A decoded image's natural ratio replaces this provisional ratio.
@@ -1320,6 +1330,50 @@ pub struct LayoutStyle {
pub box_shadow: Option<BoxShadow>,
}
/// Intrinsic metadata exposed by a decoded replaced resource.
///
/// Presence of this value means metadata is available; each dimension may
/// still be absent independently. This is required for SVG, whose root can
/// declare one dimension, both dimensions, or only a `viewBox` ratio.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(crate) struct ReplacedIntrinsic {
pub(crate) width: Option<f32>,
pub(crate) height: Option<f32>,
pub(crate) ratio: Option<f32>,
}
impl ReplacedIntrinsic {
pub fn from_dimensions(width: f32, height: f32) -> Self {
Self {
width: Some(width),
height: Some(height),
ratio: (width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0)
.then_some(width / height),
}
}
/// Resolve the concrete natural size from CSS Images' 300x150 default
/// object size. A definite authored layout axis still overrides this
/// fallback and transfers through the intrinsic ratio.
pub fn natural_size(self) -> Option<(f32, f32)> {
let width = self.width.filter(|value| value.is_finite() && *value > 0.0);
let height = self
.height
.filter(|value| value.is_finite() && *value > 0.0);
let ratio = self.ratio.filter(|value| value.is_finite() && *value > 0.0);
match (width, height, ratio) {
(Some(width), Some(height), _) => Some((width, height)),
(Some(width), None, Some(ratio)) => Some((width, width / ratio)),
(None, Some(height), Some(ratio)) => Some((height * ratio, height)),
(Some(width), None, None) => Some((width, 150.0)),
(None, Some(height), None) => Some((300.0, height)),
(None, None, Some(ratio)) if ratio >= 2.0 => Some((300.0, 300.0 / ratio)),
(None, None, Some(ratio)) => Some((150.0 * ratio, 150.0)),
(None, None, None) => Some((300.0, 150.0)),
}
}
}
/// Whether this box has an inline outer display and participates in an inline
/// formatting context. `display` retains the inner layout mode for
/// inline-flex/grid, so this cannot be inferred from `Display::Inline` alone.
+282 -39
View File
@@ -127,7 +127,7 @@ fn network_resource_url(url: &str) -> String {
#[derive(Clone, Debug, PartialEq)]
struct RememberedContentImageIntrinsic {
resolved_url: String,
dimensions: (f32, f32),
intrinsic: crate::ReplacedIntrinsic,
}
/// Page-scoped raw resource bytes shared by layout preparation and repeated
@@ -488,7 +488,7 @@ impl RenderResourceCache {
fn seed_content_image_intrinsics(
&mut self,
tree: &DomTree,
intrinsic: &mut HashMap<obscura_dom::tree::NodeId, (f32, f32)>,
intrinsic: &mut HashMap<obscura_dom::tree::NodeId, crate::ReplacedIntrinsic>,
selected: &mut HashMap<obscura_dom::tree::NodeId, SelectedImage>,
) -> HashSet<obscura_dom::tree::NodeId> {
let remembered = self
@@ -506,7 +506,7 @@ impl RenderResourceCache {
self.forget_content_image_intrinsic(nid);
continue;
}
intrinsic.insert(nid, value.dimensions);
intrinsic.insert(nid, value.intrinsic);
selected.insert(
nid,
SelectedImage {
@@ -524,7 +524,7 @@ impl RenderResourceCache {
&mut self,
nid: obscura_dom::tree::NodeId,
resolved_url: String,
dimensions: (f32, f32),
intrinsic: crate::ReplacedIntrinsic,
) {
if self.max_content_image_intrinsics == 0 {
return;
@@ -544,7 +544,7 @@ impl RenderResourceCache {
nid,
RememberedContentImageIntrinsic {
resolved_url,
dimensions,
intrinsic,
},
);
}
@@ -6920,13 +6920,20 @@ fn image_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
}
fn image_metadata_from_bytes(bytes: &[u8]) -> Option<(f32, f32)> {
let (width, height) = image_dimensions(bytes)
.map(|(width, height)| (width as f32, height as f32))
.or_else(|| svg_intrinsic(bytes))?;
image_intrinsic_metadata(bytes)?.natural_size()
}
fn image_intrinsic_metadata(bytes: &[u8]) -> Option<crate::ReplacedIntrinsic> {
let metadata = image_dimensions(bytes)
.map(|(width, height)| {
crate::ReplacedIntrinsic::from_dimensions(width as f32, height as f32)
})
.or_else(|| svg_image_intrinsic_metadata(bytes))?;
let (width, height) = metadata.natural_size()?;
if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 {
return None;
}
Some((width, height))
Some(metadata)
}
fn background_image_rect(
@@ -7546,7 +7553,7 @@ fn collect_image_intrinsics(
base_url: Option<&str>,
cache: &mut RenderResourceCache,
) -> (
HashMap<obscura_dom::tree::NodeId, (f32, f32)>,
HashMap<obscura_dom::tree::NodeId, crate::ReplacedIntrinsic>,
HashMap<obscura_dom::tree::NodeId, SelectedImage>,
) {
let mut out = std::collections::HashMap::new();
@@ -7578,16 +7585,12 @@ fn collect_image_intrinsics(
let Some(bytes) = fetch_profiled_image_bytes(&resolved_url, None, cache, profile) else {
continue;
};
let dimensions = image_dimensions(&bytes)
.map(|(width, height)| (width as f32, height as f32))
.or_else(|| svg_intrinsic(&bytes));
if let Some((w, h)) = dimensions {
if w > 0.0 && h > 0.0 {
// A 2x (or w-descriptor) candidate's raw pixels are density
// times its CSS size; divide so layout sees CSS px, or every
// responsive image occupies twice its design size.
out.insert(nid, (w / density, h / density));
}
if let Some(mut intrinsic) = image_intrinsic_metadata(&bytes) {
// A 2x (or w-descriptor) candidate's raw pixels are density times
// its CSS size. A ratio is dimensionless and remains unchanged.
intrinsic.width = intrinsic.width.map(|width| width / density);
intrinsic.height = intrinsic.height.map(|height| height / density);
out.insert(nid, intrinsic);
}
}
(out, selected)
@@ -7601,9 +7604,9 @@ fn collect_content_image_intrinsics(
styles: &std::collections::HashMap<obscura_dom::tree::NodeId, crate::LayoutStyle>,
base_url: Option<&str>,
cache: &mut RenderResourceCache,
out: &mut std::collections::HashMap<obscura_dom::tree::NodeId, (f32, f32)>,
out: &mut std::collections::HashMap<obscura_dom::tree::NodeId, crate::ReplacedIntrinsic>,
selected: &mut HashMap<obscura_dom::tree::NodeId, SelectedImage>,
source_intrinsic: &HashMap<obscura_dom::tree::NodeId, (f32, f32)>,
source_intrinsic: &HashMap<obscura_dom::tree::NodeId, crate::ReplacedIntrinsic>,
source_selected: &HashMap<obscura_dom::tree::NodeId, SelectedImage>,
seeded: &HashSet<obscura_dom::tree::NodeId>,
) -> bool {
@@ -7645,23 +7648,14 @@ fn collect_content_image_intrinsics(
cache.forget_content_image_intrinsic(nid);
continue;
};
let dimensions = image_dimensions(&bytes)
.map(|(width, height)| (width as f32, height as f32))
.or_else(|| svg_intrinsic(&bytes));
let Some((width, height)) = dimensions else {
let Some(intrinsic) = image_intrinsic_metadata(&bytes) else {
changed |= previous_dimensions.is_some() || remembered_url_changed;
cache.forget_content_image_intrinsic(nid);
continue;
};
if width <= 0.0 || height <= 0.0 {
changed |= previous_dimensions.is_some() || remembered_url_changed;
cache.forget_content_image_intrinsic(nid);
continue;
}
let dimensions = (width, height);
changed |= previous_dimensions != Some(dimensions) || remembered_url_changed;
out.insert(nid, dimensions);
cache.remember_content_image_intrinsic(nid, resolved_url, dimensions);
changed |= previous_dimensions != Some(intrinsic) || remembered_url_changed;
out.insert(nid, intrinsic);
cache.remember_content_image_intrinsic(nid, resolved_url, intrinsic);
}
// A remembered selection whose computed content disappeared must stop
@@ -8125,6 +8119,173 @@ fn svg_intrinsic(bytes: &[u8]) -> Option<(f32, f32)> {
}
}
/// Read intrinsic SVG dimensions without treating `viewBox` user-space
/// coordinates as CSS-pixel dimensions. `usvg::Tree::size()` necessarily
/// resolves percentage/missing root dimensions to its rendering viewport, so
/// it cannot preserve the distinction required by CSS replaced sizing.
fn svg_image_intrinsic_metadata(bytes: &[u8]) -> Option<crate::ReplacedIntrinsic> {
// The lightweight attribute pass below preserves missing/percentage axes,
// which `Tree::size()` necessarily resolves. It must not, however, turn a
// malformed XML prefix that merely resembles an SVG root into successful
// image metadata. Use the same parser as paint as the validity gate first.
let _validated = usvg::Tree::from_data(bytes, &usvg::Options::default()).ok()?;
let source = std::str::from_utf8(bytes).ok()?;
let mut remaining = source.trim_start_matches('\u{feff}').trim_start();
let tail = loop {
if let Some(after) = remaining.strip_prefix("<!--") {
let end = after.find("-->")?;
remaining = after[end + 3..].trim_start();
continue;
}
if let Some(after) = remaining.strip_prefix("<?") {
let end = after.find("?>")?;
remaining = after[end + 2..].trim_start();
continue;
}
if remaining.starts_with("<!") {
// Skip a validated DOCTYPE/declaration, including an internal
// subset whose quoted declarations may themselves contain `>`.
let mut quote = None;
let mut subset_depth = 0usize;
let end = remaining.char_indices().find_map(|(index, ch)| match quote {
Some(open) if ch == open => {
quote = None;
None
}
Some(_) => None,
None if ch == '\'' || ch == '"' => {
quote = Some(ch);
None
}
None if ch == '[' => {
subset_depth += 1;
None
}
None if ch == ']' => {
subset_depth = subset_depth.saturating_sub(1);
None
}
None if ch == '>' && subset_depth == 0 => Some(index),
None => None,
})?;
remaining = remaining[end + 1..].trim_start();
continue;
}
let after_open = remaining.strip_prefix('<')?;
let name_end = after_open
.find(|ch: char| ch.is_ascii_whitespace() || matches!(ch, '/' | '>'))
.unwrap_or(after_open.len());
let root_name = &after_open[..name_end];
if root_name.rsplit(':').next()? != "svg" {
return None;
}
break &after_open[name_end..];
};
let mut quote = None;
let end = tail.char_indices().find_map(|(index, ch)| match quote {
Some(open) if ch == open => {
quote = None;
None
}
Some(_) => None,
None if ch == '\'' || ch == '"' => {
quote = Some(ch);
None
}
None if ch == '>' => Some(index),
None => None,
})?;
let attributes = &tail[..end];
let attribute = |name: &str| -> Option<&str> {
let bytes = attributes.as_bytes();
let mut cursor = 0;
while cursor < bytes.len() {
while cursor < bytes.len()
&& (bytes[cursor].is_ascii_whitespace() || bytes[cursor] == b'/')
{
cursor += 1;
}
let name_start = cursor;
while cursor < bytes.len()
&& !bytes[cursor].is_ascii_whitespace()
&& bytes[cursor] != b'='
{
cursor += 1;
}
let found = &attributes[name_start..cursor];
while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
cursor += 1;
}
if cursor >= bytes.len() || bytes[cursor] != b'=' {
continue;
}
cursor += 1;
while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
cursor += 1;
}
if cursor >= bytes.len() || !matches!(bytes[cursor], b'\'' | b'"') {
continue;
}
let quote = bytes[cursor];
cursor += 1;
let value_start = cursor;
while cursor < bytes.len() && bytes[cursor] != quote {
cursor += 1;
}
let value = &attributes[value_start..cursor];
cursor = cursor.saturating_add(1);
if found == name {
return Some(value);
}
}
None
};
let length = |value: &str| -> Option<f32> {
let value = value.trim();
if value.is_empty() || value.ends_with('%') || value.eq_ignore_ascii_case("auto") {
return None;
}
let split = value
.find(|ch: char| !(ch.is_ascii_digit() || matches!(ch, '+' | '-' | '.' | 'e' | 'E')))
.unwrap_or(value.len());
let number = value[..split].parse::<f32>().ok()?;
let factor = match value[split..].trim().to_ascii_lowercase().as_str() {
"" | "px" => 1.0,
"in" => 96.0,
"cm" => 96.0 / 2.54,
"mm" => 96.0 / 25.4,
"q" => 96.0 / 101.6,
"pt" => 96.0 / 72.0,
"pc" => 16.0,
_ => return None,
};
let result = number * factor;
(result.is_finite() && result > 0.0).then_some(result)
};
let width = attribute("width").and_then(length);
let height = attribute("height").and_then(length);
let view_box_ratio = attribute("viewBox").and_then(|value| {
let values = value
.split(|ch: char| ch.is_ascii_whitespace() || ch == ',')
.filter(|value| !value.is_empty())
.map(str::parse::<f32>)
.collect::<Result<Vec<_>, _>>()
.ok()?;
(values.len() == 4 && values[2].is_finite() && values[3].is_finite()
&& values[2] > 0.0 && values[3] > 0.0)
.then_some(values[2] / values[3])
});
let ratio = match (width, height) {
(Some(width), Some(height)) => Some(width / height),
_ => view_box_ratio,
};
Some(crate::ReplacedIntrinsic {
width,
height,
ratio,
})
}
/// A full-pixmap clip mask admitting only the pixels inside `rect`, used to
/// crop an `object-fit: cover|none` image to its element box.
fn box_clip_mask(pw: u32, ph: u32, rect: &crate::Rect) -> Option<tiny_skia::Mask> {
@@ -9152,6 +9313,72 @@ mod tests {
use crate::dom::layout_dom_with_web_fonts;
use obscura_dom::tree_sink::parse_html;
#[test]
fn svg_image_metadata_keeps_view_box_as_ratio_only() {
let ratio_only = svg_image_intrinsic_metadata(
br#"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 576 576'/>"#,
)
.expect("ratio-only SVG metadata");
assert_eq!(ratio_only.width, None);
assert_eq!(ratio_only.height, None);
assert_eq!(ratio_only.ratio, Some(1.0));
assert_eq!(ratio_only.natural_size(), Some((150.0, 150.0)));
let explicit = svg_image_intrinsic_metadata(
br#"<svg xmlns='http://www.w3.org/2000/svg' width='120' height='80' viewBox='0 0 200 100'/>"#,
)
.expect("explicit SVG metadata");
assert_eq!(explicit.width, Some(120.0));
assert_eq!(explicit.height, Some(80.0));
assert_eq!(explicit.ratio, Some(1.5));
assert_eq!(explicit.natural_size(), Some((120.0, 80.0)));
let commented = svg_image_intrinsic_metadata(
br#"<!-- <svg width='999' height='999'/> --><svg xmlns='http://www.w3.org/2000/svg' width='12' height='8'/>"#,
)
.expect("the real root after a comment");
assert_eq!(commented.natural_size(), Some((12.0, 8.0)));
assert!(svg_image_intrinsic_metadata(
br#"<svg xmlns='http://www.w3.org/2000/svg' width='10' height='20'><g>"#,
)
.is_none());
}
#[test]
fn view_box_only_svg_transfers_definite_css_width_without_using_view_box_units() {
let square = br#"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'/>"#;
assert_eq!(
image_metadata_from_bytes(square),
Some((150.0, 150.0)),
"natural dimensions contain the ratio in the 300x150 default object"
);
let tree = parse_html(
r#"<html><head><style>
html,body { margin:0 }
#host { width:360px }
img { display:block }
#ratio { width:100%; height:auto }
</style></head><body><div id="host">
<img id="auto" src="data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20100%20100'/%3E">
<img id="ratio" src="data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20200%20100'/%3E">
<img id="explicit" src="data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='120'%20height='80'%20viewBox='0%200%20200%20100'/%3E">
</div></body></html>"#,
);
let mut resources = RenderResourceCache::default();
let prepared = prepare_dom(&tree, (500.0, 400.0), None, &mut resources).expect("layout");
let rect = |selector| {
let id = tree.query_selector(selector).unwrap().unwrap();
prepared.layout.rects[&id]
};
let auto = rect("#auto");
assert_eq!((auto.width, auto.height), (360.0, 360.0));
let ratio = rect("#ratio");
assert_eq!((ratio.width, ratio.height), (360.0, 180.0));
let explicit = rect("#explicit");
assert_eq!((explicit.width, explicit.height), (120.0, 80.0));
}
#[test]
fn native_placeholders_honor_default_author_color_opacity_and_value_state() {
let tree = parse_html(
@@ -10883,11 +11110,27 @@ mod tests {
.expect("selector")
.expect("image")
});
resources.remember_content_image_intrinsic(ids[0], "a".into(), (1.0, 1.0));
resources.remember_content_image_intrinsic(ids[1], "b".into(), (2.0, 2.0));
resources.remember_content_image_intrinsic(
ids[0],
"a".into(),
crate::ReplacedIntrinsic::from_dimensions(1.0, 1.0),
);
resources.remember_content_image_intrinsic(
ids[1],
"b".into(),
crate::ReplacedIntrinsic::from_dimensions(2.0, 2.0),
);
// Refreshing id 1 makes id 2 the oldest entry.
resources.remember_content_image_intrinsic(ids[0], "a2".into(), (3.0, 3.0));
resources.remember_content_image_intrinsic(ids[2], "c".into(), (4.0, 4.0));
resources.remember_content_image_intrinsic(
ids[0],
"a2".into(),
crate::ReplacedIntrinsic::from_dimensions(3.0, 3.0),
);
resources.remember_content_image_intrinsic(
ids[2],
"c".into(),
crate::ReplacedIntrinsic::from_dimensions(4.0, 4.0),
);
assert_eq!(resources.content_image_intrinsics.len(), 2);
assert!(resources.content_image_intrinsics.contains_key(&ids[0]));
+30
View File
@@ -248,6 +248,7 @@ pub fn ua_style(tag: &str) -> LayoutStyle {
// grows to fit unshrinkable content, needs real table layout.)
style.min_width = crate::Dimension::Px(0.0);
if tag == "table" {
style.is_table_box = true;
// Chromium's HTML UA sheet makes the table grid border-box and
// supplies the traditional two-pixel separate-border spacing.
// Author declarations and the legacy `cellspacing` hint cascade
@@ -272,6 +273,7 @@ pub fn ua_style(tag: &str) -> LayoutStyle {
} else if tag == "td" || tag == "th" {
style.display = Display::Flex;
style.internal_flex_container = true;
style.is_table_cell_box = true;
style.flex_direction = Some(taffy::FlexDirection::Column);
style.align_items = Some(taffy::AlignItems::FLEX_START);
style.padding = Edges {
@@ -713,6 +715,7 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
| "flow-root"
| "table"
| "inline-table"
| "table-cell"
| "-webkit-box"
| "-webkit-inline-box"
| "contents"
@@ -726,6 +729,7 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
// internal enum happens to equal the newly specified value.
style.internal_flex_container = false;
style.is_table_box = false;
style.is_table_cell_box = false;
style.is_inline_block = false;
style.flow_root = false;
style.display_contents = false;
@@ -769,6 +773,17 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
style.flow_root = true;
style.is_table_box = true;
}
"table-cell" => {
// A table cell establishes an internal flow container. A
// column flexbox is our cell-content wrapper stand-in; it
// is marked internal so descendants do not become CSS flex
// items and the table grid owns the cell's inline size.
style.display = crate::Display::Flex;
style.internal_flex_container = true;
style.flex_direction = Some(taffy::FlexDirection::Column);
style.align_items = Some(taffy::AlignItems::FLEX_START);
style.is_table_cell_box = true;
}
"-webkit-box" => {
// The unclamped legacy display is an old flexbox. When it
// forms a vertical line-clamp root, the post-declaration
@@ -7684,9 +7699,24 @@ mod tests {
assert!(inline_table.flow_root);
assert!(inline_table.is_table_box);
let table_cell = compute_style("div", Some("display:block; display:table-cell"));
assert_eq!(table_cell.display, Display::Flex);
assert!(table_cell.internal_flex_container);
assert!(table_cell.is_table_cell_box);
let min_before = compute_style("div", Some("min-width:50px;display:table-cell"));
let min_after = compute_style("div", Some("display:table-cell;min-width:50px"));
assert_eq!(min_before.min_width, crate::Dimension::Px(50.0));
assert_eq!(min_after.min_width, crate::Dimension::Px(50.0));
let reset_table = compute_style("div", Some("display:table; display:grid"));
assert_eq!(reset_table.display, Display::Grid);
assert!(!reset_table.is_table_box);
let reset_cell = compute_style("div", Some("display:table-cell; display:block"));
assert_eq!(reset_cell.display, Display::Block);
assert!(!reset_cell.internal_flex_container);
assert!(!reset_cell.is_table_cell_box);
}
#[test]
+11 -3
View File
@@ -3854,7 +3854,8 @@ fn display_inherit_transitively_preserves_contents_and_block_display() {
</div>
<section id="block-parent"><span id="block-child"></span></section>
<table><tr><td id="native-cell">
<div id="native-inherit" style="display:inherit"></div>
<div id="native-inherit" style="min-width:50px;display:inherit"></div>
<div id="native-inherit-reversed" style="display:inherit;min-width:50px"></div>
</td></tr></table>
"#,
);
@@ -3880,11 +3881,18 @@ fn display_inherit_transitively_preserves_contents_and_block_display() {
let native_cell = &layout.styles[&id("native-cell")];
let native_inherit = &layout.styles[&id("native-inherit")];
let native_inherit_reversed = &layout.styles[&id("native-inherit-reversed")];
assert!(native_cell.internal_flex_container);
assert_eq!(native_inherit.display, obscura_render::Display::Flex);
assert!(
!native_inherit.internal_flex_container,
"display inheritance must not copy the engine's UA-only table approximation"
native_inherit.internal_flex_container,
"inherited table-cell display must reconstruct its internal cell-content wrapper"
);
assert_eq!(native_inherit.min_width, obscura_render::Dimension::Px(50.0));
assert!(native_inherit_reversed.internal_flex_container);
assert_eq!(
native_inherit_reversed.min_width,
obscura_render::Dimension::Px(50.0)
);
}