diff --git a/Cargo.lock b/Cargo.lock index 44c3e76c0c..20dbc7aa29 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index a0c7fa6ae3..b44e0d3232 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/moli-layout/Cargo.toml b/moli-layout/Cargo.toml index 9b49877706..b968c42f48 100644 --- a/moli-layout/Cargo.toml +++ b/moli-layout/Cargo.toml @@ -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"] } diff --git a/moli-layout/src/inline.rs b/moli-layout/src/inline.rs index 58502af750..49d0741d7a 100644 --- a/moli-layout/src/inline.rs +++ b/moli-layout/src/inline.rs @@ -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) { 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( + world: &LayoutWorld, + context: &InlineFormattingContext, + layout: &mut Layout, +) { + 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::>::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::>(); + edges.sort_by_key(|(_, slots)| std::cmp::Reverse(ancestors[slots[0].0].len())); + let mut order = (0..line.len()).collect::>(); + 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> = 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, - 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); diff --git a/moli-layout/src/paint/text.rs b/moli-layout/src/paint/text.rs index a302ee0c94..c856f4629d 100644 --- a/moli-layout/src/paint/text.rs +++ b/moli-layout/src/paint/text.rs @@ -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( ); } - 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( }); 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( 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( 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( } } - 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( } } -fn update_active_inline_boxes( - context: &InlineFormattingContext, - object_id: u64, - active: &mut Vec, -) { - 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, diff --git a/moli-layout/src/stylo_to_parley.rs b/moli-layout/src/stylo_to_parley.rs index 90a2d169de..13da9c81d1 100644 --- a/moli-layout/src/stylo_to_parley.rs +++ b/moli-layout/src/stylo_to_parley.rs @@ -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), diff --git a/moli-layout/src/taffy_tree.rs b/moli-layout/src/taffy_tree.rs index be3f50e3ea..1ece773c85 100644 --- a/moli-layout/src/taffy_tree.rs +++ b/moli-layout/src/taffy_tree.rs @@ -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); } } } diff --git a/moli-layout/src/text.rs b/moli-layout/src/text.rs index 5bff63a53a..e5c03cf945 100644 --- a/moli-layout/src/text.rs +++ b/moli-layout/src/text.rs @@ -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() diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update/painting_tables.rs b/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update/painting_tables.rs index ccb0cb2bcf..3bf124d5e3 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update/painting_tables.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update/painting_tables.rs @@ -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 = ``; +document.body.innerHTML = '
xW
xW
xW
xW
'; +'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 { diff --git a/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface/extracted/inline_layout.rs b/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface/extracted/inline_layout.rs index 97c0fd36ca..be855374ac 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface/extracted/inline_layout.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface/extracted/inline_layout.rs @@ -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#""#, + "", + ] { + 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}
a{text}b
"# + ); + 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() { diff --git a/moli-system-fonts/Cargo.toml b/moli-system-fonts/Cargo.toml index 778b2e8915..861718705c 100644 --- a/moli-system-fonts/Cargo.toml +++ b/moli-system-fonts/Cargo.toml @@ -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"