fix(layout): place inline decorations at physical bidi edges

Position transparent opening and closing edges around their visual descendants
after bidi reordering and before alignment. Keep the logical edge widths used
by line breaking while honoring CSS direction for physical padding and borders.

Move the inline-object bidi patch onto current upstream Parley main and pin
the fork at 8eb00ef5e628b1bba95dcde88658deb2a5aed1b5. Adapt the new font,
metrics, whitespace and layout APIs while preserving neutral atomic analysis.
Preserve the browser's synthetic-bold policy through Fontique's host threshold.

Use structural style ancestry for inline text masks so physical edge ordering
cannot change which glyphs belong to a background-clip:text target.

Add 96 Chromium-verified decoration cases covering natural RTL and mixed text,
CSS directions, asymmetric padding and borders, and layout publication. Add a
Chromium-verified paint test for ordinary and nested LTR/RTL text masks.
This commit is contained in:
ldm0
2026-10-06 16:51:41 +08:00
committed by Donough Liu
parent d8e7c37723
commit 7fe5c0913e
11 changed files with 338 additions and 139 deletions
Generated
+52 -49
View File
@@ -1394,8 +1394,8 @@ dependencies = [
[[package]]
name = "fontique"
version = "0.11.1"
source = "git+https://github.com/lexmount/parley?rev=43ab1b7470f0e71ff8cd397a58974c658eb33652#43ab1b7470f0e71ff8cd397a58974c658eb33652"
version = "0.11.0"
source = "git+https://github.com/lexmount/parley?rev=8eb00ef5e628b1bba95dcde88658deb2a5aed1b5#8eb00ef5e628b1bba95dcde88658deb2a5aed1b5"
dependencies = [
"hashbrown 0.17.1",
"linebender_resource_handle",
@@ -1850,32 +1850,18 @@ checksum = "dc00caaa3fb3201ff7a18aa458e8c3ab042b9da154cfdf948bdde3936c31c36e"
[[package]]
name = "icu_collections"
version = "2.1.1"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
dependencies = [
"displaydoc",
"potential_utf",
"utf8_iter",
"yoke",
"zerofrom",
"zerovec",
]
[[package]]
name = "icu_locale"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "532b11722e350ab6bf916ba6eb0efe3ee54b932666afec989465f9243fe6dd60"
dependencies = [
"icu_collections",
"icu_locale_core",
"icu_locale_data",
"icu_provider",
"potential_utf",
"tinystr",
"zerovec",
]
[[package]]
name = "icu_locale_core"
version = "2.3.0"
@@ -1890,12 +1876,6 @@ dependencies = [
"zerovec",
]
[[package]]
name = "icu_locale_data"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c5f1d16b4c3a2642d3a719f18f6b06070ab0aef246a6418130c955ae08aa831"
[[package]]
name = "icu_locale_fallback"
version = "2.3.0"
@@ -1918,9 +1898,9 @@ checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8"
[[package]]
name = "icu_normalizer"
version = "2.1.1"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
dependencies = [
"icu_collections",
"icu_normalizer_data",
@@ -1932,16 +1912,17 @@ dependencies = [
[[package]]
name = "icu_normalizer_data"
version = "2.1.1"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
[[package]]
name = "icu_properties"
version = "2.1.2"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
dependencies = [
"displaydoc",
"icu_collections",
"icu_locale_core",
"icu_properties_data",
@@ -1952,9 +1933,9 @@ dependencies = [
[[package]]
name = "icu_properties_data"
version = "2.1.2"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"
checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
[[package]]
name = "icu_provider"
@@ -1975,25 +1956,26 @@ dependencies = [
[[package]]
name = "icu_segmenter"
version = "2.1.2"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a807a7488f3f758629ae86d99d9d30dce24da2fb2945d74c80a4f4a62c71db73"
checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db"
dependencies = [
"core_maths",
"icu_collections",
"icu_locale",
"icu_locale_fallback",
"icu_provider",
"icu_segmenter_data",
"potential_utf",
"smallvec",
"utf8_iter",
"zerovec",
]
[[package]]
name = "icu_segmenter_data"
version = "2.1.1"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ebbb7321d9e21d25f5660366cb6c08201d0175898a3a6f7a41ee9685af21c80"
checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad"
[[package]]
name = "ident_case"
@@ -3993,33 +3975,54 @@ dependencies = [
[[package]]
name = "parlance"
version = "0.1.0"
source = "git+https://github.com/lexmount/parley?rev=43ab1b7470f0e71ff8cd397a58974c658eb33652#43ab1b7470f0e71ff8cd397a58974c658eb33652"
source = "git+https://github.com/lexmount/parley?rev=8eb00ef5e628b1bba95dcde88658deb2a5aed1b5#8eb00ef5e628b1bba95dcde88658deb2a5aed1b5"
[[package]]
name = "parley"
version = "0.11.1"
source = "git+https://github.com/lexmount/parley?rev=43ab1b7470f0e71ff8cd397a58974c658eb33652#43ab1b7470f0e71ff8cd397a58974c658eb33652"
version = "0.11.0"
source = "git+https://github.com/lexmount/parley?rev=8eb00ef5e628b1bba95dcde88658deb2a5aed1b5#8eb00ef5e628b1bba95dcde88658deb2a5aed1b5"
dependencies = [
"fontique",
"hashbrown 0.17.1",
"linebender_resource_handle",
"parlance",
"parley_engine",
"smallvec",
]
[[package]]
name = "parley_data"
version = "0.11.0"
source = "git+https://github.com/lexmount/parley?rev=8eb00ef5e628b1bba95dcde88658deb2a5aed1b5#8eb00ef5e628b1bba95dcde88658deb2a5aed1b5"
dependencies = [
"icu_collections",
"icu_properties",
]
[[package]]
name = "parley_emoji"
version = "0.11.0"
source = "git+https://github.com/lexmount/parley?rev=8eb00ef5e628b1bba95dcde88658deb2a5aed1b5#8eb00ef5e628b1bba95dcde88658deb2a5aed1b5"
[[package]]
name = "parley_engine"
version = "0.11.0"
source = "git+https://github.com/lexmount/parley?rev=8eb00ef5e628b1bba95dcde88658deb2a5aed1b5#8eb00ef5e628b1bba95dcde88658deb2a5aed1b5"
dependencies = [
"fontique",
"harfrust",
"hashbrown 0.17.1",
"icu_locale_core",
"icu_normalizer",
"icu_properties",
"icu_segmenter",
"linebender_resource_handle",
"parlance",
"parley_data",
"parley_emoji",
"skrifa",
]
[[package]]
name = "parley_data"
version = "0.11.1"
source = "git+https://github.com/lexmount/parley?rev=43ab1b7470f0e71ff8cd397a58974c658eb33652#43ab1b7470f0e71ff8cd397a58974c658eb33652"
dependencies = [
"icu_properties",
]
[[package]]
name = "paste"
version = "1.0.15"
+2 -2
View File
@@ -98,8 +98,8 @@ exclude = ["vendor/xml5ever-0.39.0"]
license = "MIT OR Apache-2.0"
[patch.crates-io]
parley = { git = "https://github.com/lexmount/parley", rev = "43ab1b7470f0e71ff8cd397a58974c658eb33652" }
fontique = { git = "https://github.com/lexmount/parley", rev = "43ab1b7470f0e71ff8cd397a58974c658eb33652" }
parley = { git = "https://github.com/lexmount/parley", rev = "8eb00ef5e628b1bba95dcde88658deb2a5aed1b5" }
fontique = { git = "https://github.com/lexmount/parley", rev = "8eb00ef5e628b1bba95dcde88658deb2a5aed1b5" }
xml5ever = { path = "vendor/xml5ever-0.39.0" }
curl = { git = "https://github.com/lexmount/curl-rust", rev = "a0ea59f5ca1b1e4bc8627a28c29463ac96aeb758" }
curl-sys = { git = "https://github.com/lexmount/curl-rust", rev = "a0ea59f5ca1b1e4bc8627a28c29463ac96aeb758" }
+1 -1
View File
@@ -10,7 +10,7 @@ euclid = "=0.22.14"
kurbo = "=0.13.1"
moli-image = { path = "../moli-image" }
moli-system-fonts = { path = "../moli-system-fonts" }
parley = "=0.11.1"
parley = "=0.11.0"
read-fonts = "0.41.0"
stylo = "0.20"
stylo_taffy = { git = "https://github.com/DioxusLabs/blitz", rev = "5081c65811a4396f5a99b2e0aca542a4a4a6606f", default-features = false, features = ["std", "block", "flexbox", "grid", "floats"] }
+133 -29
View File
@@ -246,7 +246,7 @@ impl InlineContentWidthsMemo {
indent: f32,
options: parley::IndentOptions,
) -> parley::ContentWidths {
if !layout.inline_boxes().is_empty() {
if layout.inline_boxes().len() != 0 {
return layout.calculate_content_widths();
}
@@ -277,6 +277,90 @@ pub(crate) fn reset_inline_layout_for_probe(layout: &mut Layout<TextBrush>) {
drop(layout.break_lines());
}
/// Place CSS decorations around visual fragments after bidi reordering.
///
/// Edge widths still participate in logical line breaking. Their visual slots
/// follow CSS direction and descendant fragments, independently of the text's
/// resolved bidi levels. Process descendants first so nested decorations are
/// included when placing their parent's physical edges.
pub(crate) fn position_inline_edges<N: Copy + Debug + Eq + Hash>(
world: &LayoutWorld<N>,
context: &InlineFormattingContext,
layout: &mut Layout<TextBrush>,
) {
if !context.objects.iter().any(|object| {
matches!(
object.role,
InlineObjectRole::StartEdge | InlineObjectRole::EndEdge
)
}) {
return;
}
for line_index in 0..layout.len() {
let line = layout.get(line_index).expect("known line");
let mut ancestors = vec![Vec::new(); line.len()];
for run in line.runs() {
ancestors[run.index()] =
overlapping_output_ranges(&context.text_units, &run.text_range())
.iter()
.filter(|unit| !unit.control)
.flat_map(|unit| unit.ancestors.iter().copied())
.collect();
}
let mut edges = BTreeMap::<usize, Vec<(usize, bool)>>::new();
for (slot, inline_box) in line.inline_box_indices() {
let Some(object) = context.object(inline_box.id) else {
continue;
};
ancestors[slot] = object.ancestors.clone();
if matches!(
object.role,
InlineObjectRole::StartEdge | InlineObjectRole::EndEdge
) {
let physical_left = (object.role == InlineObjectRole::StartEdge)
== (world.boxes[object.box_id.index()].style.direction()
== InlineDirection::Ltr);
edges
.entry(object.box_id.index())
.or_default()
.push((slot, physical_left));
}
}
let mut edges = edges.into_iter().collect::<Vec<_>>();
edges.sort_by_key(|(_, slots)| std::cmp::Reverse(ancestors[slots[0].0].len()));
let mut order = (0..line.len()).collect::<Vec<_>>();
for (box_index, mut slots) in edges {
let original_position = order
.iter()
.position(|slot| slots.iter().any(|(edge, _)| edge == slot))
.expect("edge on this line");
order.retain(|slot| !slots.iter().any(|(edge, _)| edge == slot));
let box_id = LayoutBoxId::from_index(box_index);
let first = order
.iter()
.position(|slot| ancestors[*slot].contains(&box_id));
let last = order
.iter()
.rposition(|slot| ancestors[*slot].contains(&box_id));
slots.sort_by_key(|(_, left)| !left);
if let (Some(first), Some(last)) = (first, last) {
if let Some((slot, _)) = slots.iter().find(|(_, left)| !left) {
order.insert(last + 1, *slot);
}
if let Some((slot, _)) = slots.iter().find(|(_, left)| *left) {
order.insert(first, *slot);
}
} else {
let position = original_position.min(order.len());
order.splice(position..position, slots.iter().map(|(slot, _)| *slot));
}
}
if order.iter().enumerate().any(|(index, &slot)| index != slot) {
layout.reorder_line_items(line_index, &order);
}
}
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct InlineStrutMetrics {
line_ascent: f32,
@@ -377,6 +461,17 @@ impl InlineFormattingContext {
.unwrap_or(self.root_style)
}
pub(crate) fn style_is_within_box(&self, index: usize, target: LayoutBoxId) -> bool {
let mut current = Some(self.style_parent(index));
while let Some(box_id) = current {
if box_id == target {
return true;
}
current = self.structural_box(box_id).map(|state| state.parent);
}
false
}
fn box_includes_used_font_metrics(&self, box_id: LayoutBoxId) -> bool {
if box_id == self.root_style {
return self.root_includes_used_font_metrics;
@@ -502,14 +597,10 @@ pub(crate) fn build_inline_fragments(
}
for run in line.runs() {
let run_metrics = run.metrics();
let run_metrics = run.font_metrics();
for cluster in run.visual_clusters() {
let range = cluster.text_range();
let style_index = cluster
.glyphs()
.next()
.map(|glyph| glyph.style_index())
.unwrap_or_default();
let style_index = usize::from(cluster.style_index());
let vertical_offset = placement.map_or(0.0, |placement| {
placement.glyph_offset(run.index(), style_index)
});
@@ -740,7 +831,7 @@ pub(crate) fn break_inline_lines(
.map(|line| {
let metrics = line.metrics();
let line_range = line.text_range();
metrics.trailing_whitespace > 0.0
metrics.hanging_advance > 0.0
&& metrics.advance > width + tolerance
&& overlapping_output_ranges(&context.text_units, &line_range)
.iter()
@@ -820,12 +911,12 @@ fn line_items_with_content<'a>(
PositionedLayoutItem::GlyphRun(glyph_run) => glyph_run
.run()
.clusters()
.filter(|cluster| std::ptr::eq(cluster.first_style(), glyph_run.style()))
.filter(|cluster| std::ptr::eq(cluster.style(), glyph_run.style()))
.map(|cluster| cluster.text_range())
.reduce(|left, right| left.start.min(right.start)..left.end.max(right.end)),
PositionedLayoutItem::InlineBox(positioned) => usize::try_from(positioned.id)
.ok()
.and_then(|index| layout.inline_boxes().get(index))
.and_then(|index| layout.inline_boxes().nth(index))
.map(|object| object.index..object.index),
};
let mut content_range: Option<Range<usize>> = None;
@@ -862,7 +953,7 @@ fn glyph_run_is_collapsible_whitespace(
let only_spaces = glyph_run
.run()
.clusters()
.filter(|cluster| std::ptr::eq(cluster.first_style(), glyph_run.style()))
.filter(|cluster| std::ptr::eq(cluster.style(), glyph_run.style()))
.all(|cluster| {
found = true;
let units = overlapping_output_ranges(&context.text_units, &cluster.text_range());
@@ -931,9 +1022,12 @@ fn resolve_inline_lines(
.map(|(item, contributes_to_line)| match item {
PositionedLayoutItem::GlyphRun(glyph_run) => {
let run = glyph_run.run();
let run_metrics = run.metrics();
let run_metrics = run.font_metrics();
let paint = glyph_run.style().brush.paint;
let style_index = glyph_run.glyphs().next().map(|glyph| glyph.style_index());
let style_index = glyph_run
.glyphs()
.next()
.map(|_| usize::from(glyph_run.style_index()));
let structural_parent =
style_index.map_or(context.root_style, |index| context.style_parent(index));
let primary_strut = style_index
@@ -942,6 +1036,7 @@ fn resolve_inline_lines(
let bounds = glyph_line_bounds(
primary_strut,
run_metrics,
run.line_height(),
context.box_includes_used_font_metrics(structural_parent),
);
InlineItemVerticalGeometry {
@@ -1061,8 +1156,8 @@ fn resolve_inline_lines(
});
let fallback_root_bounds = InlineVerticalBounds {
top: -metrics.ascent - metrics.leading * 0.5,
bottom: metrics.descent + metrics.leading * 0.5,
top: metrics.block_min_coord - metrics.baseline,
bottom: metrics.block_max_coord - metrics.baseline,
};
let mut root_bounds = (!phantom && !context.uses_quirks_line_height).then(|| {
context
@@ -1374,13 +1469,14 @@ fn line_break_metrics(
let style_index = layout
.styles()
.iter()
.position(|style| std::ptr::eq(style, cluster.first_style()))?;
.position(|style| std::ptr::eq(style, cluster.style()))?;
let structural_parent = context.style_parent(style_index);
let primary_strut =
context.font_metrics[style_index].map(|metrics| inline_strut_metrics(metrics, true));
let bounds = glyph_line_bounds(
primary_strut,
run.metrics(),
run.font_metrics(),
run.line_height(),
context.box_includes_used_font_metrics(structural_parent),
);
Some((structural_parent, bounds))
@@ -1468,14 +1564,15 @@ impl InlineVerticalBounds {
fn glyph_line_bounds(
primary_strut: Option<InlineStrutMetrics>,
used_font: &parley::layout::RunMetrics,
used_font: &parley::FontMetrics,
used_line_height: f32,
include_used_font_metrics: bool,
) -> InlineVerticalBounds {
let used_strut = inline_strut_metrics(
InlineFontMetrics {
ascent: used_font.ascent,
descent: used_font.descent,
line_height: used_font.line_height,
line_height: used_line_height,
x_height: used_font.x_height.unwrap_or(used_font.ascent * 0.56),
},
true,
@@ -1518,7 +1615,7 @@ fn build_line_inline_box_states(
let follows_wrapped_space = || {
geometry
.object_index
.and_then(|index| layout.inline_boxes().get(index))
.and_then(|index| layout.inline_boxes().nth(index))
.filter(|edge| edge.index <= line_range.start)
.is_some_and(|edge| {
context
@@ -2044,6 +2141,8 @@ impl InlineBuildInput {
index: object_anchors[object_id],
width: 0.0,
height: 0.0,
baseline: None,
vertical_align: parley::VerticalAlign::default(),
},
object.role.parley_bidi(),
);
@@ -2831,18 +2930,18 @@ mod tests {
text_descent: 2.0,
x_height: 4.0,
};
let fallback = parley::layout::RunMetrics {
let fallback = parley::FontMetrics {
ascent: 18.0,
descent: 6.0,
line_height: 30.0,
..parley::layout::RunMetrics::default()
leading: 6.0,
..parley::FontMetrics::fallback(0.0)
};
let explicit = glyph_line_bounds(Some(primary), &fallback, false);
let explicit = glyph_line_bounds(Some(primary), &fallback, 30.0, false);
assert_eq!(explicit.top, -8.0);
assert_eq!(explicit.bottom, 2.0);
let normal = glyph_line_bounds(Some(primary), &fallback, true);
let normal = glyph_line_bounds(Some(primary), &fallback, 30.0, true);
assert_eq!(normal.top, -21.0);
assert_eq!(normal.bottom, 9.0);
}
@@ -2974,6 +3073,8 @@ mod tests {
index: anchors[id],
width: 1.0,
height: 20.0,
baseline: None,
vertical_align: parley::VerticalAlign::default(),
},
object.role.parley_bidi(),
);
@@ -3159,12 +3260,13 @@ mod tests {
parley::Alignment::Justify,
parley::AlignmentOptions {
align_when_overflowing: false,
last_line_alignment: None,
},
);
let justified = reused.calculate_content_widths();
assert!(
justified.max > expected.max,
"the regression fixture must expose justification-mutated cluster advances"
assert_eq!(
justified.max, expected.max,
"Parley keeps line justification separate from intrinsic shaped advances"
);
reset_inline_layout_for_probe(&mut reused);
@@ -3217,10 +3319,12 @@ mod tests {
index: 5,
width: 20.0,
height: 10.0,
baseline: None,
vertical_align: parley::VerticalAlign::default(),
});
let mut object_layout = object_builder.build(text);
let object_first = memo.content_widths_for_probe(&object_layout, 12.0, changed_options);
object_layout.inline_boxes_mut()[0].width = 60.0;
object_layout.inline_boxes_mut().next().unwrap().width = 60.0;
let object_second = memo.content_widths_for_probe(&object_layout, 12.0, changed_options);
assert!(object_second.max > object_first.max);
assert_eq!(memo.hits, 1);
+19 -44
View File
@@ -1,16 +1,19 @@
use parley::{Affinity, Cursor, PositionedLayoutItem, Selection};
use parley::{
Affinity, PositionedLayoutItem,
editing::{Cursor, Selection},
};
use super::{PaintProjectionMetrics, cull::rects_intersect};
use crate::{
LayoutBox, LayoutBoxId, LayoutRect, LayoutTransform2D, PaintBrush, PaintColor, PaintFragment,
PaintGlyph, PaintGlyphRun, PaintShape, PaintSnapshot, PaintTextDecoration, PaintTextShadow,
inline::{InlineFormattingContext, InlineObjectRole, InlinePaintBounds, InlineSelection},
inline::{InlineFormattingContext, InlinePaintBounds, InlineSelection},
};
const SELECTION_COLOR: PaintColor = PaintColor::new(180.0 / 255.0, 213.0 / 255.0, 1.0, 1.0);
// These expansion ratios and the 0.3 px cap follow Blitz's AnyRender text
// painter at d788124a. Moli records them only when Fontique/Parley asks
// for faux bold and the computed font-synthesis-weight permits it.
// painter at d788124a. Moli combines the matched face's synthesis metadata with
// the computed CSS weight threshold and font-synthesis-weight permission.
const SYNTHETIC_EMBOLDEN_X_EM: f32 = 0.015_125;
const SYNTHETIC_EMBOLDEN_Y_EM: f32 = 0.012_1;
const MAX_SYNTHETIC_EMBOLDEN_PX: f32 = 0.3;
@@ -113,7 +116,6 @@ fn project_text_phase<N>(
);
}
let mut active_inline_boxes = Vec::new();
for (line_index, line) in text_layout.lines().enumerate() {
metrics.text_line_count = metrics.text_line_count.saturating_add(1);
let line_is_culled =
@@ -136,34 +138,16 @@ fn project_text_phase<N>(
});
if line_is_culled {
metrics.culled_text_line_count = metrics.culled_text_line_count.saturating_add(1);
// A background-clip:text replay tracks structural inline start/end
// markers across lines. Even when a line's glyph ink is culled,
// retain that tiny state transition so a later visible line uses
// the correct InlineBox mask scope.
if mask_scope.is_some() {
for item in line.items() {
if let PositionedLayoutItem::InlineBox(positioned) = item {
update_active_inline_boxes(
context,
positioned.id,
&mut active_inline_boxes,
);
}
}
}
continue;
}
let line_placement = context.line_placements.get(line_index);
for (item_index, item) in line.items().enumerate() {
let glyph_run = match item {
PositionedLayoutItem::InlineBox(positioned) => {
update_active_inline_boxes(context, positioned.id, &mut active_inline_boxes);
continue;
}
PositionedLayoutItem::InlineBox(_) => continue,
PositionedLayoutItem::GlyphRun(glyph_run) => glyph_run,
};
if let Some(TextClipMaskScope::InlineBox(target)) = mask_scope
&& !active_inline_boxes.contains(&target)
&& !context.style_is_within_box(usize::from(glyph_run.style_index()), target)
{
continue;
}
@@ -185,9 +169,11 @@ fn project_text_phase<N>(
if glyphs.is_empty() {
continue;
}
let font = snapshot.intern_font(run.font());
let font = snapshot.intern_font(&run.font().font);
let synthesis = run.synthesis();
let glyph_embolden = if glyph_run.style().brush.synthetic_bold && synthesis.embolden() {
let glyph_embolden = if glyph_run.style().brush.synthetic_bold
&& synthesis.embolden_with_threshold(0.0)
{
let font_size = run.font_size().max(0.0);
crate::PaintPoint::new(
(SYNTHETIC_EMBOLDEN_X_EM * font_size).min(MAX_SYNTHETIC_EMBOLDEN_PX),
@@ -199,7 +185,11 @@ fn project_text_phase<N>(
let owned_run = PaintGlyphRun {
font,
font_size: run.font_size(),
normalized_coords: run.normalized_coords().to_vec(),
normalized_coords: run
.normalized_coords()
.iter()
.map(|coord| coord.to_bits())
.collect(),
color: if phase == TextPaintPhase::ClipMask {
PaintColor::BLACK
} else {
@@ -222,7 +212,7 @@ fn project_text_phase<N>(
}
}
let metrics = run.metrics();
let metrics = run.font_metrics();
let baseline = origin_y + glyph_run.baseline() + vertical_offset;
let x = origin_x + glyph_run.offset();
let width = glyph_run.advance().max(0.0);
@@ -277,21 +267,6 @@ fn project_text_phase<N>(
}
}
fn update_active_inline_boxes(
context: &InlineFormattingContext,
object_id: u64,
active: &mut Vec<LayoutBoxId>,
) {
let Some(object) = context.object(object_id) else {
return;
};
active.clear();
active.extend(object.ancestors.iter().copied());
if object.role == InlineObjectRole::StartEdge {
active.push(object.box_id);
}
}
fn project_selection(
context: &InlineFormattingContext,
text_layout: &parley::Layout<crate::stylo_to_parley::TextBrush>,
+20 -4
View File
@@ -30,10 +30,10 @@ use style::{
use crate::{PaintColor, PaintPoint, PaintTextDecorationStyle, style::absolute_paint_color};
// Blink only requests synthetic bold for CSS weights at or above 600. Fontique
// reports any requested weight above the selected face as embolden-able, which
// also includes the CSS 500 -> regular 400 match unless we preserve this
// browser-level threshold at the style bridge.
// Blink only requests synthetic bold for CSS weights at or above 600. The
// matched face's synthesis metadata separately records whether its static
// weight is below the request, including the CSS 500 -> regular 400 match.
// Keep this browser threshold independent of the library's default policy.
const SYNTHETIC_BOLD_THRESHOLD: f32 = 600.0;
#[derive(Clone, Copy, Debug, Default, PartialEq)]
@@ -168,6 +168,22 @@ pub(crate) fn text_style(computed: &ComputedValues) -> TextStyle<'static, 'stati
};
TextStyle {
line_break: parley::LineBreak::default(),
vertical_align: parley::VerticalAlign::default(),
white_space_collapse: match computed.clone_white_space_collapse() {
style::computed_values::white_space_collapse::T::Collapse => {
parley::WhiteSpaceCollapse::Collapse
}
style::computed_values::white_space_collapse::T::Preserve => {
parley::WhiteSpaceCollapse::Preserve
}
style::computed_values::white_space_collapse::T::PreserveBreaks => {
parley::WhiteSpaceCollapse::PreserveBreaks
}
style::computed_values::white_space_collapse::T::BreakSpaces => {
parley::WhiteSpaceCollapse::BreakSpaces
}
},
font_family: FontFamily::List(Cow::Owned(families)),
font_size,
font_width: font_width(font.font_stretch),
+6 -4
View File
@@ -20,8 +20,8 @@ use crate::{
inline::{
InlineContentWidthsMemo, InlineEdgeContribution, 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,
build_inline_line_placements, measure_inline_lines, position_inline_edges,
relative_atomic_inset_offset, reset_inline_layout_for_probe,
},
positioned::{
FlexCrossAxisStaticContext, HorizontalStaticEdge, PhysicalStaticPosition,
@@ -2366,7 +2366,7 @@ where
vec![InlineEdgeContribution::default(); context.objects.len()];
let mut floats = Vec::new();
for (inline_box, object) in layout.inline_boxes_mut().iter_mut().zip(&context.objects) {
for (inline_box, object) in layout.inline_boxes_mut().zip(&context.objects) {
match object.role {
InlineObjectRole::Atomic => {
let margins = self.boxes[object.box_id.index()]
@@ -2652,10 +2652,12 @@ where
} else {
break_inline_lines(context, layout, max_advance);
}
position_inline_edges(self, context, layout);
layout.align(
alignment,
AlignmentOptions {
align_when_overflowing: false,
last_line_alignment: None,
},
);
@@ -2854,7 +2856,7 @@ where
state.set_line_max_advance(slot.width.max(0.0));
state.set_line_x(slot.x);
state.set_line_y(f64::from(slot.y));
state.append_inline_box_to_line(data.advance, 0.0);
state.append_inline_box_to_line(data.advance, 0.0, 0.0);
}
}
}
+6 -5
View File
@@ -350,16 +350,16 @@ impl ParleyDocumentServices {
let mut layout = builder.build(&candidate);
layout.break_all_lines(None);
let run = layout.lines().next()?.runs().next()?;
if primary_font
.is_some_and(|identity| identity != (run.font().data.id(), run.font().index))
{
if primary_font.is_some_and(|identity| {
identity != (run.font().font.data.id(), run.font().font.index)
}) {
return None;
}
let metrics = *run.metrics();
let metrics = *run.font_metrics();
Some(InlineFontMetrics {
ascent: metrics.ascent,
descent: metrics.descent,
line_height: metrics.line_height,
line_height: run.line_height(),
x_height: resolved_inline_x_height(metrics.ascent, metrics.x_height),
})
});
@@ -1199,6 +1199,7 @@ mod tests {
.next()
.expect("one shaped run")
.font()
.font
.data
.as_ref()
.to_vec()
@@ -1555,6 +1555,55 @@ document.body.innerHTML = `
.expect("fresh inline SVG replaced-resource fixture should run");
}
#[tokio::test(flavor = "current_thread")]
async fn inline_text_clip_membership_survives_visual_edge_reordering() {
run_page_vm_async_test(async move {
let loader =
crate::network::ResourceRequestClient::new(&FetchConfig::default()).expect("loader");
let mut page_vm = test_page_vm_with_loader_and_document_url(
&loader,
Vec::new(),
Url::parse("https://example.com/bidi-inline-text-clip.html")?,
);
// Chromium 145.0.7632.116 gives equal, visible ink in all four rows.
page_vm.vm_mut().eval(
r#"
document.head.innerHTML = `<style>
html,body{margin:0;padding:0;background:white}.case{font:32px/40px monospace;height:40px}
em{font-style:normal}.clip{padding-left:20px;background-image:linear-gradient(90deg,red,blue);background-repeat:no-repeat;background-clip:text;-webkit-text-fill-color:transparent}
.sibling{-webkit-text-fill-color:transparent}
</style>`;
document.body.innerHTML = '<div class=case><span class=clip style="direction:ltr">x</span><span class=sibling>W</span></div><div class=case><span class=clip style="direction:rtl">x</span><span class=sibling>W</span></div><div class=case><span class=clip style="direction:ltr"><em>x</em></span><span class=sibling>W</span></div><div class=case><span class=clip style="direction:rtl"><em>x</em></span><span class=sibling>W</span></div>';
'installed'
"#,
)?;
let snapshot = page_vm
.vm_mut()
.screenshot_layout_snapshot(moli_layout::PaintViewport::new(160, 160, 1.0))?
.expect("text clip fixture must retain a layout root");
let image = moli_paint::raster_snapshot(&snapshot)?;
let colored_ink = [0, 40, 80, 120].map(|top| {
(top..top + 40)
.flat_map(|y| (0..image.width).map(move |x| (x, y)))
.filter(|(x, y)| {
let offset = ((y * image.width + x) * 4) as usize;
let pixel = &image.rgba[offset..offset + 4];
pixel[3] == 255 && pixel[0].max(pixel[2]).saturating_sub(pixel[1]) > 20
})
.count()
});
assert!(colored_ink[0] > 50, "the control must paint visible text ink");
assert_eq!(
colored_ink,
[colored_ink[0]; 4],
"CSS direction and nested styles must preserve the text mask's membership"
);
Ok::<_, anyhow::Error>(())
})
.await
.expect("bidi text clip test should run");
}
#[tokio::test(flavor = "current_thread")]
async fn screenshot_paints_background_clip_text_with_transparent_webkit_fill() {
run_page_vm_async_test(async move {
@@ -1,5 +1,54 @@
use super::*;
// Physical gaps verified in Chromium 145.0.7632.116 before adding assertions.
#[test]
fn inline_decorations_follow_visual_edges_of_natural_bidi_text() {
for doctype in [
"",
r#"<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">"#,
"<!doctype html>",
] {
for direction in ["ltr", "rtl"] {
for text in ["אב", "xאב", "אבx", "xאבx"] {
for (decoration, left, right) in [
("padding-left:20px", 20.0, 0.0),
("padding-right:13px", 0.0, 13.0),
("padding-left:20px;padding-right:13px", 20.0, 13.0),
("border-left:7px solid;border-right:3px solid", 7.0, 3.0),
] {
let markup = format!(
r#"{doctype}<meta charset=utf-8><style>body{{margin:0}}#line{{direction:ltr;font:20px/30px monospace;width:300px}}</style><div id=line>a<span id=w style="direction:{direction};{decoration}">{text}</span>b</div>"#
);
let mut vm = new_parsed_test_vm("https://visual-inline-edges.test/", &markup);
let query = r#"JSON.stringify((()=>{
const word=document.getElementById('w'),range=document.createRange();
range.selectNodeContents(word);
const box=word.getBoundingClientRect(),text=range.getBoundingClientRect();
return [text.left-box.left,box.right-text.right,
document.getElementById('line').getBoundingClientRect().height,
word.textContent.length];
})())"#;
let first = vm.eval(query).unwrap();
let actual = serde_json::from_str::<[f64; 4]>(&first).unwrap();
let expected = [left, right, 30.0, text.chars().count() as f64];
for (actual, expected) in actual.into_iter().zip(expected) {
assert!(
(actual - expected).abs() < 0.02,
"{doctype}/{direction}/{text}/{decoration}: {first}"
);
}
publish_layout_for_test(&mut vm);
assert_eq!(
vm.eval(query).unwrap(),
first,
"{doctype}/{direction}/{text}/{decoration}"
);
}
}
}
}
}
// Relative geometry measured in Chromium 145.0.7632.116 in all three modes.
#[test]
fn atomic_inline_boxes_resolve_bidi_contexts_without_expanding_sibling_fragments() {
+1 -1
View File
@@ -5,7 +5,7 @@ edition = "2024"
license = "(MIT OR Apache-2.0) AND BSD-3-Clause"
[dependencies]
fontique = "=0.11.1"
fontique = "=0.11.0"
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies]
parking_lot = "0.12"