diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index b8602b1..6f7e211 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -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; + 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + intrinsic: &ReplacedIntrinsicMap, fonts: &[crate::inline::WebFont], pass_limit: Option, 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, + intrinsic: &ReplacedIntrinsicMap, fonts: &[crate::inline::WebFont], pass_limit: Option, 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, + 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, 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> = (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> = (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 = 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 = (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) { /// 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, +) -> 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], + percentages: &[Option], +) -> Vec { + 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 = minimums.iter().map(|value| value.max(0.0)).collect(); + let prefs: Vec = 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> = 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 = (0..count) + .map(|index| { + effective_percentages[index] + .map(|percentage| (percentage * target).max(mins[index])) + .unwrap_or(mins[index]) + }) + .collect(); + let guess_min_spec: Vec = (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 = (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::(); + 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 `` 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-`` stack could not do: each @@ -9400,10 +9529,50 @@ fn build_table( styles: &HashMap, ) -> Option { 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> = 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
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 { let mut best: Option = 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#" +
+
+
+
+
+
"#, + ); + 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#" +
+
+ direct text +
+
"#, + ); + 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#" +
Addon label
Button
"#, + ); + 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( diff --git a/crates/obscura-render/src/inline.rs b/crates/obscura-render/src/inline.rs index efe2a0a..5ddf751 100644 --- a/crates/obscura-render/src/inline.rs +++ b/crates/obscura-render/src/inline.rs @@ -732,7 +732,8 @@ const REPLACED_CONTEXT_BIT: usize = 1usize << (usize::BITS - 1); #[derive(Clone, Copy)] struct ReplacedItem { - intrinsic_width: f32, + intrinsic_width: Option, + intrinsic_height: Option, preferred_width: Option, preferred_height: Option, preferred_ratio: f32, @@ -740,6 +741,10 @@ struct ReplacedItem { min_height: Option, max_width: Option, max_height: Option, + /// 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 { 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), diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index cfdc25e..ab5d110 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -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 + /// `
`/`` 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 `
`. /// 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, /// The current ratio came from HTML width/height presentation hints /// (`` or its selected ``), 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, } +/// 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, + pub(crate) height: Option, + pub(crate) ratio: Option, +} + +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. diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index ade6d1d..7b21b60 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -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, + intrinsic: &mut HashMap, selected: &mut HashMap, ) -> HashSet { 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 { + 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, + HashMap, HashMap, ) { 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, base_url: Option<&str>, cache: &mut RenderResourceCache, - out: &mut std::collections::HashMap, + out: &mut std::collections::HashMap, selected: &mut HashMap, - source_intrinsic: &HashMap, + source_intrinsic: &HashMap, source_selected: &HashMap, seeded: &HashSet, ) -> 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 { + // 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("")?; + remaining = after[end + 3..].trim_start(); + continue; + } + if let Some(after) = remaining.strip_prefix("")?; + remaining = after[end + 2..].trim_start(); + continue; + } + if remaining.starts_with("`. + 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 { + 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::().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::) + .collect::, _>>() + .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 { @@ -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#""#, + ) + .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#""#, + ) + .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#""#, + ) + .expect("the real root after a comment"); + assert_eq!(commented.natural_size(), Some((12.0, 8.0))); + + assert!(svg_image_intrinsic_metadata( + br#""#, + ) + .is_none()); + } + + #[test] + fn view_box_only_svg_transfers_definite_css_width_without_using_view_box_units() { + let square = br#""#; + 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#"
+ + + +
"#, + ); + 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])); diff --git a/crates/obscura-render/src/style.rs b/crates/obscura-render/src/style.rs index 88c5c34..44d9272 100644 --- a/crates/obscura-render/src/style.rs +++ b/crates/obscura-render/src/style.rs @@ -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] diff --git a/crates/obscura-render/tests/layout_test.rs b/crates/obscura-render/tests/layout_test.rs index c747462..1a20d71 100644 --- a/crates/obscura-render/tests/layout_test.rs +++ b/crates/obscura-render/tests/layout_test.rs @@ -3854,7 +3854,8 @@ fn display_inherit_transitively_preserves_contents_and_block_display() {
-
+
+
"#, ); @@ -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) ); }