fix(layout): anchor inline out-of-flow boxes to line boxes

Retain the hypothetical outer display in the inline object stream. Resolve static positions from CSS line-box edges rather than glyph baselines or ancestor vertical-align offsets, and carry the source IFC direction across containing-block changes. Cover 26 font-independent Chromium differential cases.
This commit is contained in:
ldm0
2026-09-08 06:11:22 +08:00
parent acb59e7107
commit bf2aa5de15
5 changed files with 194 additions and 29 deletions
+70 -10
View File
@@ -103,11 +103,19 @@ pub(crate) struct SourceOrigin {
pub(crate) enum InlineObjectRole {
Atomic,
Float,
OutOfFlow,
OutOfFlow(OutOfFlowDisplay),
StartEdge,
EndEdge,
}
/// Positioning blockifies the used display. Inline layout must retain the
/// hypothetical outer display to choose the current line or the next line.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum OutOfFlowDisplay {
Inline,
Block,
}
#[derive(Clone, Debug)]
pub(crate) struct InlineObject {
pub(crate) box_id: LayoutBoxId,
@@ -587,7 +595,7 @@ pub(crate) fn build_inline_fragments(
}
InlineObjectRole::Atomic
| InlineObjectRole::Float
| InlineObjectRole::OutOfFlow => {}
| InlineObjectRole::OutOfFlow(_) => {}
}
}
}
@@ -898,7 +906,7 @@ fn resolve_inline_lines(
.and_then(|index| structural_edge_contributions.get(index))
.copied()
.unwrap_or(false),
InlineObjectRole::Float | InlineObjectRole::OutOfFlow => false,
InlineObjectRole::Float | InlineObjectRole::OutOfFlow(_) => false,
}),
glyph_key: None,
anchor: LineVerticalAnchor::Root,
@@ -1074,13 +1082,57 @@ fn resolve_inline_lines(
states[state_index].global_offset = states[state_index].relative_offset
+ anchor_global_offset(states[state_index].anchor, &states);
}
// Static positions belong to the CSS line box, not an inline
// baseline or an ancestor's vertical-align state. Like Blink's
// PlaceOutOfFlowObjects, block-level placeholders move to the
// next line only when in-flow content logically precedes them.
// Parley exposes items in visual order, so RTL searches backwards.
let first_in_flow = if layout.is_rtl() {
geometries
.iter()
.rposition(|geometry| geometry.creates_line)
} else {
geometries.iter().position(|geometry| geometry.creates_line)
};
let line_top = raw_top + preceding_adjustment;
let item_offsets = geometries
.iter()
.map(|geometry| {
let desired_top = root_baseline
+ anchor_global_offset(geometry.anchor, &states)
+ geometry.relative_offset
+ geometry.bounds.top;
.zip(line.items())
.enumerate()
.map(|(index, (geometry, item))| {
let static_display = match item {
PositionedLayoutItem::InlineBox(positioned) => context
.object(positioned.id)
.and_then(|object| match object.role {
InlineObjectRole::OutOfFlow(display) => Some(display),
_ => None,
}),
PositionedLayoutItem::GlyphRun(_) => None,
};
let desired_top = match static_display {
Some(OutOfFlowDisplay::Inline) => line_top,
Some(OutOfFlowDisplay::Block) => {
let has_preceding_content = first_in_flow.is_some_and(|first| {
if layout.is_rtl() {
index < first
} else {
index > first
}
});
line_top
+ if has_preceding_content {
line_height
} else {
0.0
}
}
None => {
root_baseline
+ anchor_global_offset(geometry.anchor, &states)
+ geometry.relative_offset
+ geometry.bounds.top
}
};
desired_top - geometry.initial_top
})
.collect::<Vec<_>>();
@@ -1928,9 +1980,17 @@ fn collect_box<N>(
let out_of_flow = world.boxes[id.index()].style.is_out_of_flow();
if out_of_flow {
let display = if world.boxes[id.index()]
.style
.hypothetical_display_is_inline_level()
{
OutOfFlowDisplay::Inline
} else {
OutOfFlowDisplay::Block
};
normalizer.push_object(
id,
InlineObjectRole::OutOfFlow,
InlineObjectRole::OutOfFlow(display),
InlineBoxKind::OutOfFlow,
ancestors,
world.boxes[id.index()].style.vertical_align(),
@@ -2292,7 +2352,7 @@ impl InlineNormalizer {
self.flush_pending_carriage_return();
if matches!(
role,
InlineObjectRole::Atomic | InlineObjectRole::Float | InlineObjectRole::OutOfFlow
InlineObjectRole::Atomic | InlineObjectRole::Float | InlineObjectRole::OutOfFlow(_)
) {
self.flush_pending();
self.line_has_content = true;
+19 -16
View File
@@ -18,8 +18,9 @@ use crate::{
LayoutWorld, PaintRect, PaintViewport,
inline::{
InlineContentWidthsMemo, InlineFormattingContext, InlineFragments, InlineLinePlacement,
InlineObjectRole, break_inline_lines, build_inline_fragments, build_inline_line_placements,
measure_inline_lines, relative_atomic_inset_offset, reset_inline_layout_for_probe,
InlineObjectRole, OutOfFlowDisplay, break_inline_lines, build_inline_fragments,
build_inline_line_placements, measure_inline_lines, relative_atomic_inset_offset,
reset_inline_layout_for_probe,
},
positioned::resolve_absolute_axis_margins,
replaced::measure_replaced,
@@ -961,7 +962,7 @@ where
child,
area,
static_in_area,
static_position.inline_level,
static_position.direction,
numeric_parent_origin,
);
} else {
@@ -970,7 +971,7 @@ where
child,
area,
static_in_area,
area.direction == taffy::Direction::Rtl && static_position.inline_level,
static_position.direction == InlineDirection::Rtl,
numeric_parent_origin,
);
}
@@ -1120,7 +1121,7 @@ fn layout_inline_absolute_child<N>(
child: LayoutBoxId,
area: PositionedContainingArea,
static_position: Point<f32>,
inline_level: bool,
static_direction: InlineDirection,
numeric_parent_origin: Point<f32>,
) where
N: Copy + Debug + Eq + Hash,
@@ -1224,9 +1225,7 @@ fn layout_inline_absolute_child<N>(
let available_width = match (left, right) {
(Some(left), None) => area_width - left,
(None, Some(right)) => area_width - right,
(None, None) if area.direction == taffy::Direction::Rtl && inline_level => {
static_position.x
}
(None, None) if static_direction == InlineDirection::Rtl => static_position.x,
(None, None) => area_width - static_position.x,
(Some(_), Some(_)) => unreachable!("both insets already resolve auto width"),
} - non_auto_margin_width;
@@ -1333,7 +1332,7 @@ fn layout_inline_absolute_child<N>(
}
(Some(left), None) => left + resolved_margin.left,
(None, Some(right)) => area_width - final_size.width - right - resolved_margin.right,
(None, None) if area.direction == taffy::Direction::Rtl && inline_level => {
(None, None) if static_direction == InlineDirection::Rtl => {
static_position.x - final_size.width - resolved_margin.right
}
(None, None) => static_position.x + resolved_margin.left,
@@ -2129,7 +2128,7 @@ where
margins,
});
}
InlineObjectRole::OutOfFlow => {
InlineObjectRole::OutOfFlow(_) => {
inline_box.width = 0.0;
inline_box.height = 0.0;
}
@@ -2582,20 +2581,24 @@ where
let vertical_offset = line_placement
.map(|placement| placement.item_offset(item_index))
.unwrap_or_default();
if object.role == InlineObjectRole::OutOfFlow {
let inline_level = self.boxes[object.box_id.index()]
.style
.hypothetical_display_is_inline_level();
if let InlineObjectRole::OutOfFlow(display) = object.role {
let inline_offset = match display {
OutOfFlowDisplay::Inline => positioned.x,
OutOfFlowDisplay::Block => match container_direction {
InlineDirection::Ltr => 0.0,
InlineDirection::Rtl => containing_block_size.width,
},
};
self.boxes[object.box_id.index()].inline_static_position =
Some(InlineStaticPosition {
owner: self.boxes[object.box_id.index()]
.inline_context_owner
.unwrap_or_else(|| panic!("out-of-flow IFC object lost its owner")),
point: Point {
x: content_offset.x + if inline_level { positioned.x } else { 0.0 },
x: content_offset.x + inline_offset,
y: content_offset.y + positioned.y + vertical_offset,
},
inline_level,
direction: container_direction,
});
continue;
}
+7 -3
View File
@@ -6,8 +6,10 @@ use taffy::{Cache, Layout, Point, Style};
use crate::{
LayoutCssImageReference, LayoutElementSemantics, LayoutError, LayoutPoint, LayoutPseudo,
LayoutResolvedGridTracks, LayoutScrollbarAxis, LayoutScrollbarColors, LayoutScrollbarGutter,
LayoutScrollbarWidth, ResolvedLayoutStyle, inline::InlineFormattingContext,
replaced::ReplacedContext, style::LayoutOverflowMode,
LayoutScrollbarWidth, ResolvedLayoutStyle,
inline::InlineFormattingContext,
replaced::ReplacedContext,
style::{InlineDirection, LayoutOverflowMode},
};
/// Dense identifier scoped to exactly one [`LayoutWorld`].
@@ -190,7 +192,9 @@ pub enum LayoutCapabilityDiagnostic {
pub(crate) struct InlineStaticPosition {
pub(crate) owner: LayoutBoxId,
pub(crate) point: Point<f32>,
pub(crate) inline_level: bool,
/// Direction of the formatting context that produced the static point,
/// which need not be the direction of the actual containing block.
pub(crate) direction: InlineDirection,
}
impl LayoutCapabilityDiagnostic {
@@ -832,6 +832,60 @@ html, body { display: block; margin: 0; padding: 0 }
}));
}
#[test]
fn layout_renderer_anchors_inline_static_positions_to_line_boxes() {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("current-thread runtime should build");
runtime.block_on(tokio::task::LocalSet::new().run_until(async move {
let mut page = parse_phase_one_html_into_page_vm_for_test_with_env(
include_str!("../../../tests/fixtures/inline-static-position.html"),
default_test_page_vm_env_config_with(|env| {
env.layout_policy = moli_page_types::LayoutPolicy::OnDemand;
}),
)
.await;
page.vm_mut().sync_live_document_style_sources();
page.vm_mut()
.screenshot_layout_snapshot(moli_layout::PaintViewport::new(800, 600, 1.0))
.expect("inline static-position layout should succeed")
.expect("fixture should have a document element");
let geometry = page
.vm_mut()
.eval(
r#"
JSON.stringify(Array.from(document.querySelectorAll('.slot'), slot => {
const origin = slot.getBoundingClientRect();
const item = slot.querySelector('.item').getBoundingClientRect();
return {
id: slot.id,
actual: [item.x - origin.x, item.y - origin.y, item.width, item.height],
expected: slot.dataset.expected.split(',').map(Number)
};
}))
"#,
)
.expect("read published inline geometry");
let cases: serde_json::Value = serde_json::from_str(&geometry).expect("geometry JSON");
let cases = cases.as_array().expect("geometry cases");
assert_eq!(cases.len(), 26);
for case in cases {
for axis in 0..4 {
let actual = case["actual"][axis].as_f64().expect("actual coordinate");
let expected = case["expected"][axis]
.as_f64()
.expect("expected coordinate");
assert!(
(actual - expected).abs() <= 1.0 / 64.0,
"{}[{axis}]: expected {expected}, got {actual}",
case["id"],
);
}
}
}));
}
#[test]
fn layout_renderer_preserves_calc_min_width_in_float_intrinsic_contribution() {
let runtime = tokio::runtime::Builder::new_current_thread()
@@ -0,0 +1,44 @@
<!doctype html>
<meta charset="utf-8">
<title>Out-of-flow static positions belong to CSS line boxes</title>
<style>
html, body { margin: 0; padding: 0; }
html { overflow: hidden; }
.slot { display: flow-root; position: relative; width: 200px; height: 120px; font-size: 0; line-height: 30px; }
.atom { display: inline-block; width: 30px; height: 10px; }
.item { display: inline; position: absolute; width: 20px; height: 10px; background: green; }
.block { display: block; }
.rtl { direction: rtl; }
</style>
<!-- Zero-size text and fixed-size in-flow boxes isolate static positioning
from installed fonts. Rectangles are relative to the .slot border box. -->
<div class="slot" id="inline-before" data-expected="0,0,20,10"><span class="item"></span><span class="atom"></span></div>
<div class="slot" id="inline-after" data-expected="30,0,20,10"><span class="atom"></span><span class="item"></span></div>
<div class="slot" id="inline-next-line" data-expected="30,30,20,10"><span class="atom"></span><br><span class="atom"></span><span class="item"></span></div>
<div class="slot" id="inline-raised-ancestor" data-expected="30,0,20,10"><span class="atom"></span><span style="vertical-align:40px"><span class="item"></span></span></div>
<div class="slot" id="inline-tall-line" data-expected="30,0,20,10"><span class="atom" style="height:50px"></span><span class="item"></span></div>
<div class="slot" id="inline-margins" data-expected="41,3,20,10"><span class="atom"></span><span class="item" style="margin:3px 7px 9px 11px"></span></div>
<div class="slot" id="inline-empty" data-expected="0,0,20,10"><span class="item"></span></div>
<div class="slot" id="block-before" data-expected="0,0,20,10"><span class="item block"></span><span class="atom"></span></div>
<div class="slot" id="block-after" data-expected="0,30,20,10"><span class="atom"></span><span class="item block"></span></div>
<div class="slot" id="block-next-line" data-expected="0,30,20,10"><span class="atom"></span><br><span class="item block"></span><span class="atom"></span></div>
<div class="slot" id="block-tall-line" data-expected="0,65,20,10"><span class="atom" style="height:50px"></span><span class="item block"></span></div>
<div class="slot" id="block-empty" data-expected="0,0,20,10"><span class="item block"></span></div>
<!-- Real RTL text supplies the paragraph direction; its zero font size keeps
these positioning assertions independent of the selected glyph font.
Paragraph direction and bidi levels of leading inline objects are a
separate Parley input limitation, not part of line-box block placement. -->
<div class="slot rtl" id="rtl-inline-before-atomic" data-expected="180,0,20,10">א<span class="item"></span><span class="atom"></span></div>
<div class="slot rtl" id="rtl-inline-after-atomic" data-expected="150,0,20,10">א<span class="atom"></span><span class="item"></span></div>
<div class="slot rtl" id="rtl-block-before-content" data-expected="180,0,20,10"><span class="item block"></span><span class="atom"></span></div>
<div class="slot rtl" id="rtl-block-after-content" data-expected="180,30,20,10">א<span class="atom"></span><span class="item block"></span></div>
<div class="slot" id="padded-line" data-expected="43,9,20,10" style="padding:7px 11px;border:2px solid"><span class="atom"></span><span class="item"></span></div>
<div class="slot" id="indented-line" data-expected="55,0,20,10" style="text-indent:25px"><span class="atom"></span><span class="item"></span></div>
<div class="slot" id="indented-block" data-expected="0,30,20,10" style="text-indent:25px"><span class="atom"></span><span class="item block"></span></div>
<div class="slot" id="different-containing-block" data-expected="41,7,20,10"><div style="margin:7px 11px"><span class="atom"></span><span class="item"></span></div></div>
<div class="slot" id="definite-top" data-expected="30,17,20,10"><span class="atom"></span><span class="item" style="top:17px"></span></div>
<div class="slot" id="inline-after-float" data-expected="50,0,20,10"><span style="float:left;width:20px;height:10px"></span><span class="atom"></span><span class="item"></span></div>
<div class="slot" id="block-after-float" data-expected="0,30,20,10"><span style="float:left;width:20px;height:10px"></span><span class="atom"></span><span class="item block"></span></div>
<div class="slot" id="block-after-only-out-of-flow" data-expected="0,0,20,10"><span style="position:absolute"></span><span class="item block"></span><span class="atom"></span></div>
<div class="slot" id="rtl-ifc-ltr-containing-block" data-expected="139,7,20,10"><div style="direction:rtl;margin:7px 11px">א<span class="atom"></span><span class="item"></span></div></div>
<div class="slot rtl" id="ltr-ifc-rtl-containing-block" data-expected="41,7,20,10"><div style="direction:ltr;margin:7px 11px"><span class="atom"></span><span class="item"></span></div></div>