From 3e25fb22ea79c30302f0499fda425105e3329066 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Tue, 8 Sep 2026 06:54:25 +0800 Subject: [PATCH] fix(layout): resolve paragraph and inline-object bidi directions Provide the CSS paragraph direction to Parley, distinguish neutral objects from transparent inline boundaries, and use resolved item direction for inline static-position edges. Pin a minimal Parley 0.11.1 extension without upgrading the shaping pipeline. Add 34 Chromium-verified geometry cases and direction-restyle coverage. --- Cargo.lock | 12 ++-- Cargo.toml | 2 + moli-layout/src/inline.rs | 44 +++++++++--- moli-layout/src/taffy_tree.rs | 11 ++- moli-layout/src/world.rs | 5 +- moli-renderer-v8/src/runtime/phase_one/mod.rs | 71 +++++++++++++++++++ .../tests/fixtures/inline-bidi-objects.html | 52 ++++++++++++++ 7 files changed, 177 insertions(+), 20 deletions(-) create mode 100644 moli-renderer-v8/tests/fixtures/inline-bidi-objects.html diff --git a/Cargo.lock b/Cargo.lock index 82903b196f..067212fe2d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1347,8 +1347,7 @@ dependencies = [ [[package]] name = "fontique" version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6688bc1294fe7117d788937b6c53480169b29c566954af490830d4c09da9516a" +source = "git+https://github.com/ldm0/parley?rev=ad5b8d7a24c8827fae74ec430a8b2636381db8d6#ad5b8d7a24c8827fae74ec430a8b2636381db8d6" dependencies = [ "hashbrown 0.17.1", "linebender_resource_handle", @@ -3722,14 +3721,12 @@ dependencies = [ [[package]] name = "parlance" version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b6937eda350acc1a5d05872c3cbf99fe78619c269096e2be3d4a350058639d5" +source = "git+https://github.com/ldm0/parley?rev=ad5b8d7a24c8827fae74ec430a8b2636381db8d6#ad5b8d7a24c8827fae74ec430a8b2636381db8d6" [[package]] name = "parley" version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22d2ff88bd3f7d68d1d9b09c7e6209f9a8e8c05088295140a2bcf2e9b17038c5" +source = "git+https://github.com/ldm0/parley?rev=ad5b8d7a24c8827fae74ec430a8b2636381db8d6#ad5b8d7a24c8827fae74ec430a8b2636381db8d6" dependencies = [ "fontique", "harfrust", @@ -3746,8 +3743,7 @@ dependencies = [ [[package]] name = "parley_data" version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1567535334d6ba2d3cde19221ba9a7bd0fabb3cbd99046ddfb10ae061cfcc889" +source = "git+https://github.com/ldm0/parley?rev=ad5b8d7a24c8827fae74ec430a8b2636381db8d6#ad5b8d7a24c8827fae74ec430a8b2636381db8d6" dependencies = [ "icu_properties", ] diff --git a/Cargo.toml b/Cargo.toml index c103aeac3b..4b0769f518 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -87,6 +87,8 @@ exclude = ["vendor/xml5ever-0.39.0"] license = "MIT OR Apache-2.0" [patch.crates-io] +# Keep the 0.11.1 shaping pipeline with explicit paragraph and inline-object bidi analysis. +parley = { git = "https://github.com/ldm0/parley", rev = "ad5b8d7a24c8827fae74ec430a8b2636381db8d6" } xml5ever = { path = "vendor/xml5ever-0.39.0" } curl = { git = "https://github.com/ldm0/curl-rust", branch = "moli" } curl-sys = { git = "https://github.com/ldm0/curl-rust", branch = "moli" } diff --git a/moli-layout/src/inline.rs b/moli-layout/src/inline.rs index ba8def41ff..6dc4929515 100644 --- a/moli-layout/src/inline.rs +++ b/moli-layout/src/inline.rs @@ -14,7 +14,10 @@ use std::{ ops::Range, }; -use parley::{BreakReason, InlineBox, InlineBoxKind, Layout, PositionedLayoutItem, TextStyle}; +use parley::{ + BaseDirection, BreakReason, InlineBox, InlineBoxBidi, InlineBoxKind, Layout, + PositionedLayoutItem, TextStyle, +}; use taffy::{MaybeResolve as _, Point, Size}; use crate::{ @@ -1751,6 +1754,19 @@ impl InlineBuildInput { 1.0, quantize, ); + let root_style = &world.boxes[self.root_style.index()].style; + // CSS supplies the paragraph direction except for plaintext, which + // resolves its base direction from the first strong character. + builder.set_base_direction( + if root_style.unicode_bidi() == InlineUnicodeBidi::Plaintext { + BaseDirection::Auto + } else { + match root_style.direction() { + InlineDirection::Ltr => BaseDirection::Ltr, + InlineDirection::Rtl => BaseDirection::Rtl, + } + }, + ); let style_indices = styles .iter() .map(|style| builder.push_style(style.clone())) @@ -1763,14 +1779,24 @@ impl InlineBuildInput { builder.push_style_run(style_indices[*style_slot], range.clone()); } } - for (object_id, (byte_index, _, kind)) in self.objects.iter().enumerate() { - builder.push_inline_box(InlineBox { - id: u64::try_from(object_id).expect("one IFC exceeded the u64 object limit"), - kind: *kind, - index: *byte_index, - width: 0.0, - height: 0.0, - }); + for (object_id, (byte_index, object, kind)) in self.objects.iter().enumerate() { + let bidi = match object.role { + InlineObjectRole::StartEdge => InlineBoxBidi::StartBoundary, + InlineObjectRole::EndEdge => InlineBoxBidi::EndBoundary, + InlineObjectRole::Atomic + | InlineObjectRole::Float + | InlineObjectRole::OutOfFlow(_) => InlineBoxBidi::Neutral, + }; + builder.push_inline_box_with_bidi( + InlineBox { + id: u64::try_from(object_id).expect("one IFC exceeded the u64 object limit"), + kind: *kind, + index: *byte_index, + width: 0.0, + height: 0.0, + }, + bidi, + ); } let layout = builder.build(&self.text); let font_metrics = styles diff --git a/moli-layout/src/taffy_tree.rs b/moli-layout/src/taffy_tree.rs index eebb38d4ec..fa93e4da46 100644 --- a/moli-layout/src/taffy_tree.rs +++ b/moli-layout/src/taffy_tree.rs @@ -2771,6 +2771,15 @@ where .map(|placement| placement.item_offset(item_index)) .unwrap_or_default(); if let InlineObjectRole::OutOfFlow(display) = object.role { + // Inline-level placeholders use the resolved item's bidi + // direction, which can differ from the paragraph direction. + // A block-level placeholder remains attached to the IFC's + // inline-start edge (Blink LogicalLineBuilder). + let direction = match display { + OutOfFlowDisplay::Inline if positioned.is_rtl() => InlineDirection::Rtl, + OutOfFlowDisplay::Inline => InlineDirection::Ltr, + OutOfFlowDisplay::Block => container_direction, + }; let inline_offset = match display { OutOfFlowDisplay::Inline => positioned.x, OutOfFlowDisplay::Block => match container_direction { @@ -2787,7 +2796,7 @@ where x: content_offset.x + inline_offset, y: content_offset.y + positioned.y + vertical_offset, }, - direction: container_direction, + direction, }); continue; } diff --git a/moli-layout/src/world.rs b/moli-layout/src/world.rs index 824e0b533f..45ff9df3bc 100644 --- a/moli-layout/src/world.rs +++ b/moli-layout/src/world.rs @@ -192,8 +192,9 @@ pub enum LayoutCapabilityDiagnostic { pub(crate) struct InlineStaticPosition { pub(crate) owner: LayoutBoxId, pub(crate) point: Point, - /// Direction of the formatting context that produced the static point, - /// which need not be the direction of the actual containing block. + /// Resolved bidi direction for an inline-level placeholder, or the IFC's + /// direction for a block-level placeholder. Neither is necessarily the + /// direction of the actual containing block. pub(crate) direction: InlineDirection, } diff --git a/moli-renderer-v8/src/runtime/phase_one/mod.rs b/moli-renderer-v8/src/runtime/phase_one/mod.rs index df7eb54f84..19f2318bef 100644 --- a/moli-renderer-v8/src/runtime/phase_one/mod.rs +++ b/moli-renderer-v8/src/runtime/phase_one/mod.rs @@ -1053,6 +1053,77 @@ JSON.stringify(Array.from(document.querySelectorAll('.slot'), slot => { })); } + #[test] + fn layout_renderer_resolves_css_paragraph_and_inline_object_directions() { + 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-bidi-objects.html"), + default_test_page_vm_env_config_with(|env| { + env.layout_policy = moli_page_types::LayoutPolicy::OnDemand; + }), + ) + .await; + // Exercise the initial build and rebuilding an existing IFC in + // both directions. Only explicit captures publish new geometry. + for direction in ["rtl", "ltr", "rtl"] { + page.vm_mut() + .eval(&format!( + "{{ const slot = document.getElementById('dynamic-direction'); \ + slot.style.direction = '{direction}'; \ + slot.dataset.expected = '{}'; }}", + if direction == "rtl" { + "150,0,20,10" + } else { + "30,0,20,10" + }, + )) + .expect("update paragraph direction"); + page.vm_mut().sync_live_document_style_sources(); + page.vm_mut() + .screenshot_layout_snapshot(moli_layout::PaintViewport::new(800, 600, 1.0)) + .expect("bidi fixture 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 bidi 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(), 34); + 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}] after {direction}: 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() diff --git a/moli-renderer-v8/tests/fixtures/inline-bidi-objects.html b/moli-renderer-v8/tests/fixtures/inline-bidi-objects.html new file mode 100644 index 0000000000..aa7971b1a8 --- /dev/null +++ b/moli-renderer-v8/tests/fixtures/inline-bidi-objects.html @@ -0,0 +1,52 @@ + + +CSS paragraph direction and inline object bidi ordering + +
+
+
+
א
+
א
+
a
+
12
+
...
+
א
+
א
+
ab
+
אב
+
ab
+
+
+
+
ab
+
אב
+
+
+
+
+
+
+
אב
+
+
+

+
+
+
+
a
+
א
+