render: preserve float flow bands

This commit is contained in:
SGavrl
2026-07-23 14:09:57 +00:00
parent 1f85dfce6b
commit 735375ee33

View File

@@ -2041,7 +2041,7 @@ fn build(
}
let mut child_ids: Vec<taffy::NodeId> = if has_float_child {
build_children_with_float_zone(tree, &dom_children, taffy_tree, id_map, words, engine, ifc_items, styles)
build_children_with_float_zone(tree, id, &dom_children, taffy_tree, id_map, words, engine, ifc_items, styles)
} else {
dom_children.into_iter().flat_map(|cid| build_any(tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles)).collect()
};
@@ -2154,12 +2154,29 @@ fn build_mixed_block(
child_ids.extend(build_any(tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles));
}
Seg::Run(run) => {
// Collapsible source formatting at the start/end of an inline
// run does not create line width. Preserve whitespace between
// inline siblings, but trim indentation adjacent to block
// boundaries so pretty-printed markup starts at the line edge.
let is_whitespace_text = |cid: NodeId| {
tree.get_node(cid).map_or(false, |node| {
matches!(node.data, obscura_dom::tree::NodeData::Text { .. })
&& tree.text_content(cid).trim().is_empty()
})
};
let start = run.iter().position(|&cid| !is_whitespace_text(cid)).unwrap_or(run.len());
let end = run
.iter()
.rposition(|&cid| !is_whitespace_text(cid))
.map(|index| index + 1)
.unwrap_or(start);
let run = &run[start..end];
let join_before = before_pending && i == 0;
let join_after = after_pending && i + 1 == n_segs;
// Fast path: the whole run folds to one shaped leaf, unless
// pseudo-content word leaves must share its lines.
if !join_before && !join_after {
if let Some(item) = engine.try_build_run(tree, id, &run, styles) {
if let Some(item) = engine.try_build_run(tree, id, run, styles) {
let leaf = taffy_tree.new_leaf_with_context(run_leaf_style(), item).ok()?;
ifc_items.runs.entry(id).or_default().push(item);
child_ids.push(leaf);
@@ -2171,7 +2188,7 @@ fn build_mixed_block(
atoms.extend(before_leaves.iter().copied());
before_pending = false;
}
for &rc in &run {
for &rc in run {
atoms.extend(build_any(tree, rc, taffy_tree, id_map, words, engine, ifc_items, styles));
}
if join_after {
@@ -2261,16 +2278,16 @@ fn run_wrapper_style(parent: &crate::LayoutStyle) -> taffy::Style {
/// Approximate `float: left|right` without real per-line reflow (which
/// taffy's block/flex/grid modes do not provide): place the float alongside
/// the flow siblings that follow it, up to whichever comes first of the next
/// heading or another floated element, then let everything from there on
/// revert to normal full-width flow.
/// the flow siblings that follow it until their estimated height reaches the
/// float's estimated bottom, a matching `clear` is encountered, or another
/// float begins, then let everything from there on revert to normal full-width
/// flow.
///
/// This is not a general CSS float implementation (a float taller than its
/// flow zone, or one that should keep affecting content past a heading,
/// won't reflow correctly), but it directly targets the overwhelmingly
/// estimated flow zone won't reflow correctly), but it directly targets the overwhelmingly
/// common real-world shape: a floated image or infobox near the top of an
/// article, sitting beside the intro text, with the rest of the content
/// (starting at the next section heading) running full width beneath it.
/// running full width once normal flow passes the float.
/// Rough height budget for a float with no explicit size and no images
/// (an icon-only or empty float, rare in practice): enough for a couple of
/// lines of caption-sized text without being so generous it drags in a
@@ -2303,7 +2320,29 @@ fn estimate_float_height(tree: &DomTree, float_id: NodeId, styles: &HashMap<Node
.sum();
const ASSUMED_FLOAT_WIDTH: f32 = 280.0;
let text_height = estimate_text_height(tree, float_id, styles, ASSUMED_FLOAT_WIDTH);
(image_height + text_height).max(DEFAULT_FLOAT_HEIGHT_ESTIMATE)
// Flattened character count misses forced rows. A sidebar list with twenty
// short `<li>`s or an infobox table with many `<tr>`s is much taller than
// the same text treated as one wrapping paragraph. Add one line for each
// structural row; the continuous-text estimate still accounts for extra
// wrapping within those rows.
let structural_height: f32 = tree
.descendants(float_id)
.into_iter()
.filter(|&id| {
tree.get_node(id)
.and_then(|node| node.as_element().map(|element| element.local.to_string()))
.map(|local| {
matches!(
local.as_str(),
"li" | "tr" | "dt" | "dd" | "p" | "figcaption" | "h1" | "h2" | "h3" | "h4"
| "h5" | "h6"
)
})
.unwrap_or(false)
})
.map(|id| styles.get(&id).and_then(|style| style.font_size).unwrap_or(16.0) * 1.2)
.sum();
(image_height + text_height + structural_height).max(DEFAULT_FLOAT_HEIGHT_ESTIMATE)
}
/// Estimate how tall `id`'s text content would render at `assumed_width`,
@@ -2323,6 +2362,58 @@ fn estimate_text_height(tree: &DomTree, id: NodeId, styles: &HashMap<NodeId, cra
lines * fsize * 1.2 + 16.0
}
/// Estimate the normal-flow height consumed by one sibling alongside a float.
/// Text alone is insufficient for image grids and fixed-height boxes, which
/// otherwise cost zero budget and remain squeezed beside a float long after
/// they should have passed its bottom.
fn estimate_flow_sibling_height(
tree: &DomTree,
id: NodeId,
styles: &HashMap<NodeId, crate::LayoutStyle>,
assumed_width: f32,
) -> f32 {
let style = styles.get(&id);
if style.map(|style| style.display == crate::Display::None).unwrap_or(false)
|| style
.and_then(|style| style.position)
.map(|position| position == taffy::Position::Absolute)
.unwrap_or(false)
{
return 0.0;
}
let explicit_height = match style.map(|style| style.height) {
Some(crate::Dimension::Px(height)) => height.max(0.0),
_ => 0.0,
};
let descendant_image_height: f32 = tree
.descendants(id)
.into_iter()
.filter(|&descendant| {
tree.get_node(descendant)
.and_then(|node| node.as_element().map(|element| element.local.as_ref() == "img"))
.unwrap_or(false)
})
.filter_map(|descendant| match styles.get(&descendant).map(|style| style.height) {
Some(crate::Dimension::Px(height)) => Some(height.max(0.0)),
_ => None,
})
.sum();
let own_image_height = if tree
.get_node(id)
.and_then(|node| node.as_element().map(|element| element.local.as_ref() == "img"))
.unwrap_or(false)
{
explicit_height
} else {
0.0
};
let content_height = estimate_text_height(tree, id, styles, assumed_width)
.max(explicit_height)
.max(descendant_image_height + own_image_height);
let margins = style.map(|style| (style.margin.top + style.margin.bottom).max(0.0)).unwrap_or(0.0);
content_height + margins
}
/// Largest definite (px) width among `id` and its descendants. Used to cap an
/// auto-width floated figure: a Wikipedia thumbnail is `<figure ...><img
/// width=250><figcaption>long text</figcaption></figure>`, and without a cap
@@ -2344,6 +2435,7 @@ fn max_definite_descendant_width(tree: &DomTree, id: NodeId, styles: &HashMap<No
fn build_children_with_float_zone(
tree: &DomTree,
parent_id: NodeId,
dom_children: &[NodeId],
taffy_tree: &mut TaffyTree<usize>,
id_map: &mut HashMap<taffy::NodeId, NodeId>,
@@ -2353,12 +2445,6 @@ fn build_children_with_float_zone(
styles: &HashMap<NodeId, crate::LayoutStyle>,
) -> Vec<taffy::NodeId> {
let is_float = |cid: NodeId| styles.get(&cid).map(|s| s.float.is_some()).unwrap_or(false);
let is_heading = |cid: NodeId| {
tree.get_node(cid)
.and_then(|n| n.as_element().map(|e| e.local.to_string()))
.map(|local| matches!(local.as_str(), "h1" | "h2" | "h3" | "h4" | "h5" | "h6"))
.unwrap_or(false)
};
let Some(float_idx) = dom_children.iter().position(|&cid| is_float(cid)) else {
return dom_children.iter().flat_map(|&cid| build_any(tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles)).collect();
@@ -2409,7 +2495,8 @@ fn build_children_with_float_zone(
result.push(row);
}
result.extend(build_children_with_float_zone(
tree, &dom_children[run_end..], taffy_tree, id_map, words, engine, ifc_items, styles,
tree, parent_id, &dom_children[run_end..], taffy_tree, id_map, words, engine, ifc_items,
styles,
));
return result;
}
@@ -2417,7 +2504,7 @@ fn build_children_with_float_zone(
// Stop growing the zone once the flow siblings collected so far would
// already fill (an estimate of) the float's own height: real float
// reflow ends when normal-flow content passes the float's bottom edge,
// not at the next heading regardless of how tall the float actually is.
// Headings do not terminate a CSS float's influence by themselves.
// Without this, a short floated thumbnail (a few hundred px) dragged an
// entire multi-paragraph section into a narrow flow column alongside it
// — visibly wrong wrapping plus a large empty gap once the (much
@@ -2441,11 +2528,11 @@ fn build_children_with_float_zone(
let mut zone_end = float_idx + 1;
let mut flow_height_estimate = 0.0f32;
while zone_end < dom_children.len()
&& !is_heading(dom_children[zone_end])
&& !is_float(dom_children[zone_end])
&& !clears_this_float(dom_children[zone_end])
{
flow_height_estimate += estimate_text_height(tree, dom_children[zone_end], styles, ASSUMED_FLOW_WIDTH);
flow_height_estimate +=
estimate_flow_sibling_height(tree, dom_children[zone_end], styles, ASSUMED_FLOW_WIDTH);
zone_end += 1;
if flow_height_estimate >= float_height_budget {
break;
@@ -2477,32 +2564,57 @@ fn build_children_with_float_zone(
}
}
}
let flow_taffy: Vec<taffy::NodeId> = dom_children[float_idx + 1..zone_end]
.iter()
.flat_map(|&cid| build_any(tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles))
.collect();
match float_taffy {
Some(float_id) => {
let flow_column_style = taffy::Style {
display: taffy::style::Display::Flex,
flex_direction: taffy::FlexDirection::Column,
display: taffy::style::Display::Block,
flex_grow: 1.0,
flex_shrink: 1.0,
flex_basis: taffy::Dimension::length(0.0),
min_size: taffy::Size { width: taffy::Dimension::length(0.0), height: taffy::Dimension::auto() },
..Default::default()
};
let flow_column = if flow_taffy.is_empty() {
let flow_dom = &dom_children[float_idx + 1..zone_end];
let flow_column = if flow_dom.is_empty() {
taffy_tree.new_leaf(flow_column_style).ok()
} else {
taffy_tree.new_with_children(flow_column_style, &flow_taffy).ok()
// The zone is still an ordinary block formatting context:
// consecutive text/inline siblings must share inline runs,
// while block siblings stack. Building each sibling directly
// into a flex column makes every link a separate stretched
// row. Reuse the mixed-block builder, but leave this anonymous
// wrapper out of the DOM id map so it cannot overwrite the
// real parent's rectangle.
let mut flow_style = styles.get(&parent_id).cloned().unwrap_or_default();
flow_style.before_content = None;
flow_style.after_content = None;
let column = build_mixed_block(
tree,
parent_id,
&flow_style,
flow_column_style,
flow_dom,
taffy_tree,
id_map,
words,
engine,
ifc_items,
styles,
);
if let Some(column_id) = column {
id_map.remove(&column_id);
}
column
};
let row_style = taffy::Style {
display: taffy::style::Display::Flex,
flex_direction: taffy::FlexDirection::Row,
align_items: Some(taffy::AlignItems::FLEX_START),
size: taffy::Size {
width: taffy::Dimension::percent(1.0),
height: taffy::Dimension::auto(),
},
..Default::default()
};
let row_children: Vec<taffy::NodeId> = match float_side {
@@ -2516,7 +2628,11 @@ fn build_children_with_float_zone(
// The float itself failed to build (e.g. display:none resolved for
// it specifically); still build its flow siblings so their content
// is not silently lost.
None => result.extend(flow_taffy),
None => result.extend(
dom_children[float_idx + 1..zone_end]
.iter()
.flat_map(|&cid| build_any(tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles)),
),
}
result.extend(