diff --git a/.isu/issues.json b/.isu/issues.json index f06d8a3..14e8639 100644 --- a/.isu/issues.json +++ b/.isu/issues.json @@ -1,5 +1,5 @@ { - "next_id": 390, + "next_id": 392, "issues": [ { "id": 1, @@ -4764,6 +4764,19 @@ "author": "piefev", "state": "open", "created_at": "2026-07-16T13:13:11Z" + }, + { + "id": 391, + "repo": "we", + "title": "Bing parity remains blocked after text-shadow support", + "body": "Parent: isu issue 280.\n\nThis pass added parsed/inherited CSS text-shadow support and paints shadow glyphs before foreground text. Focused style/render tests cover the new behavior, but the Bing real-web scenario still cannot remove its xfail.\n\nRepro:\ncargo run -p we-e2e -- --scenario crates/e2e/scenarios/real-web/bing.com.we --out-dir crates/e2e/artifacts\n\nCurrent committed-golden failure after text-shadow support:\n- desktop: 4.85% match (1168922/1228500 px differ, tol=4, max_diff=0.1000%)\n- mobile: 5.17% match (312148/329160 px differ, tol=4, max_diff=0.1000%)\n\nSame-cache Chromium comparison remains far above threshold using the harness per-channel tolerance:\n- desktop: 76.98% match versus crates/e2e/artifacts/real-web/bing.com/desktop.scenario-cache.chromium.png\n- mobile: 74.94% match versus crates/e2e/artifacts/real-web/bing.com/mobile.scenario-cache.chromium.png\n\nArtifacts:\n- crates/e2e/artifacts/real-web/bing.com/desktop.png\n- crates/e2e/artifacts/real-web/bing.com/desktop.png.diff.png\n- crates/e2e/artifacts/real-web/bing.com/mobile.png\n- crates/e2e/artifacts/real-web/bing.com/mobile.png.diff.png\n- crates/e2e/artifacts/real-web/bing.com/desktop.scenario-cache.chromium.png\n- crates/e2e/artifacts/real-web/bing.com/mobile.scenario-cache.chromium.png\n\nNotes:\n- The committed Bing Chromium goldens are still stale relative to the Sichuan Tea offline fixture; see isu issue 305.\n- The remaining deterministic same-cache gap appears dominated by image/text/SVG/filter raster parity rather than DOM hydration or interaction; the DOM, carousel hydration assertions, and click assertion pass.\n\nAcceptance: after stale-golden rebaseline and deterministic renderer parity work, remove the xfail from crates/e2e/scenarios/real-web/bing.com.we and pass both screenshot assertions within the default threshold.", + "labels": [ + "real-web" + ], + "assigned": [], + "author": "piefev", + "state": "open", + "created_at": "2026-07-16T13:32:35Z" } ] } diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 88eb9d6..328a45f 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -13,8 +13,8 @@ use we_style::computed::{ BorderCollapse, BorderStyle, BoxShadow, BoxSizing, Clear, ComputedStyle, Cursor, Display, FlexDirection, FlexWrap, Float, FontStyle, GridAutoFlow, GridPlacement, GridTrackSize, JustifyContent, JustifyItems, JustifySelf, LengthOrAuto, Overflow, Position, StyledNode, - TextAlign, TextDecoration, TextOverflow, TextTransform, Transform, Visibility, WhiteSpace, - WillChange, + TextAlign, TextDecoration, TextOverflow, TextShadow, TextTransform, Transform, Visibility, + WhiteSpace, WillChange, }; use we_text::font::Font; @@ -65,6 +65,7 @@ pub struct TextLine { pub width: f32, pub font_size: f32, pub color: Color, + pub text_shadows: Vec, pub text_decoration: TextDecoration, pub background_color: Color, /// True if the text should be rendered bold (font-weight >= 600). @@ -184,6 +185,8 @@ pub struct LayoutBox { pub lines: Vec, /// Text color. pub color: Color, + /// CSS `text-shadow` layers. + pub text_shadows: Vec, /// Background color. pub background_color: Color, /// Background image URL, if any. @@ -331,6 +334,7 @@ impl LayoutBox { line_clamp: style.line_clamp, lines: Vec::new(), color: style.color, + text_shadows: style.text_shadows.clone(), background_color: style.background_color, background_image: style.background_image.as_ref().map(|url| url.to_string()), background_gradient: style.background_gradient.clone(), @@ -5588,6 +5592,7 @@ enum InlineItemKind { text: String, font_size: f32, color: Color, + text_shadows: Vec, text_decoration: TextDecoration, background_color: Color, bold: bool, @@ -5601,6 +5606,7 @@ enum InlineItemKind { node: NodeId, font_size: f32, color: Color, + text_shadows: Vec, text_decoration: TextDecoration, background_color: Color, bold: bool, @@ -5648,6 +5654,7 @@ struct PendingFragment { height: f32, font_size: f32, color: Color, + text_shadows: Vec, text_decoration: TextDecoration, background_color: Color, bold: bool, @@ -5695,6 +5702,7 @@ fn flatten_inline_tree( text: w, font_size: child.font_size, color: child.color, + text_shadows: child.text_shadows.clone(), text_decoration: child.text_decoration, background_color: child.background_color, bold: child.bold, @@ -5706,6 +5714,7 @@ fn flatten_inline_tree( node, font_size: child.font_size, color: child.color, + text_shadows: child.text_shadows.clone(), text_decoration: child.text_decoration, background_color: child.background_color, bold: child.bold, @@ -6023,6 +6032,7 @@ fn layout_inline_children( text, font_size, color, + text_shadows, text_decoration, background_color, bold, @@ -6066,6 +6076,7 @@ fn layout_inline_children( height: 0.0, font_size: *font_size, color: *color, + text_shadows: text_shadows.clone(), text_decoration: *text_decoration, background_color: *background_color, bold: *bold, @@ -6078,6 +6089,7 @@ fn layout_inline_children( node, font_size, color, + text_shadows, text_decoration, background_color, bold, @@ -6103,6 +6115,7 @@ fn layout_inline_children( height: 0.0, font_size: *font_size, color: *color, + text_shadows: text_shadows.clone(), text_decoration: *text_decoration, background_color: *background_color, bold: *bold, @@ -6123,6 +6136,7 @@ fn layout_inline_children( if prev_is_text && prev.text_decoration == *text_decoration && prev.color == *color + && prev.text_shadows == *text_shadows && prev.background_color == *background_color && prev.font_size == *font_size { @@ -6213,6 +6227,7 @@ fn layout_inline_children( } else { Color::new(0, 0, 0, 1) }, + text_shadows: Vec::new(), text_decoration: TextDecoration::None, background_color: Color::new(0, 0, 0, 0), bold: false, @@ -6322,6 +6337,7 @@ fn layout_inline_children( width: frag.width, font_size: frag.font_size, color: frag.color, + text_shadows: frag.text_shadows.clone(), text_decoration: frag.text_decoration, background_color: frag.background_color, bold: frag.bold, diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 477b1dc..26ae0e0 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1158,6 +1158,21 @@ fn paint_text(layout_box: &LayoutBox, list: &mut DisplayList, tx: f32, ty: f32) translated_line.x += tx; translated_line.y += ty; + for shadow in &line.text_shadows { + if shadow.color.a == 0 { + continue; + } + let mut shadow_line = translated_line.clone(); + shadow_line.x += shadow.offset_x; + shadow_line.y += shadow.offset_y; + shadow_line.color = shadow.color; + list.push(PaintCommand::DrawGlyphs { + line: shadow_line, + font_size, + color: shadow.color, + }); + } + list.push(PaintCommand::DrawGlyphs { line: translated_line, font_size, @@ -1228,6 +1243,7 @@ fn paint_list_marker(layout_box: &LayoutBox, list: &mut DisplayList, tx: f32, ty width: marker_width, font_size, color: layout_box.color, + text_shadows: Vec::new(), text_decoration: TextDecoration::None, background_color: Color::new(0, 0, 0, 0), bold: false, @@ -1683,6 +1699,7 @@ fn paint_text_input( width: layout_box.rect.width, font_size, color: text_color, + text_shadows: Vec::new(), text_decoration: TextDecoration::None, background_color: Color::new(0, 0, 0, 0), bold: false, @@ -2027,6 +2044,7 @@ fn paint_button( width: text_width, font_size, color: text_color, + text_shadows: Vec::new(), text_decoration: TextDecoration::None, background_color: Color::new(0, 0, 0, 0), bold: false, @@ -2103,6 +2121,7 @@ fn paint_select_button( width: layout_box.rect.width - 20.0, // leave room for arrow font_size, color: text_color, + text_shadows: Vec::new(), text_decoration: TextDecoration::None, background_color: Color::new(0, 0, 0, 0), bold: false, @@ -2236,6 +2255,7 @@ fn paint_select_listbox( font_size }, color: opt_color, + text_shadows: Vec::new(), text_decoration: TextDecoration::None, background_color: Color::new(0, 0, 0, 0), bold: false, @@ -2389,6 +2409,7 @@ fn paint_dropdown_overlay(dd: &PendingDropdown, list: &mut DisplayList) { width: menu_width - indent - padding, font_size: opt_font_size, color: text_color, + text_shadows: Vec::new(), text_decoration: TextDecoration::None, background_color: Color::new(0, 0, 0, 0), bold: false, @@ -2735,6 +2756,36 @@ body { margin: 0; } assert_eq!(*text_colors[0], Color::rgb(255, 0, 0)); } + #[test] + fn css_text_shadow_paints_before_foreground_text() { + let html_str = r#" + + +

Shadowed

+"#; + let doc = we_html::parse_html(html_str); + let tree = layout_doc(&doc); + let list = build_display_list(&tree); + + let glyphs: Vec<(usize, Color, f32, f32)> = list + .iter() + .enumerate() + .filter_map(|(idx, cmd)| match cmd { + PaintCommand::DrawGlyphs { line, color, .. } if line.text == "Shadowed" => { + Some((idx, *color, line.x, line.y)) + } + _ => None, + }) + .collect(); + + assert_eq!(glyphs.len(), 2, "shadow and foreground glyphs should paint"); + assert_eq!(glyphs[0].1, Color::new(0, 0, 0, 128)); + assert_eq!(glyphs[1].1, Color::rgb(255, 255, 255)); + assert!(glyphs[0].0 < glyphs[1].0); + assert!((glyphs[0].2 - glyphs[1].2 - 2.0).abs() < 0.01); + assert!((glyphs[0].3 - glyphs[1].3 - 3.0).abs() < 0.01); + } + #[test] fn css_background_color_renders() { let html_str = r#" diff --git a/crates/style/src/computed.rs b/crates/style/src/computed.rs index 0da0b74..f7a95de 100644 --- a/crates/style/src/computed.rs +++ b/crates/style/src/computed.rs @@ -787,6 +787,15 @@ pub struct BoxShadow { pub color: Color, } +/// One computed CSS `text-shadow` layer. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct TextShadow { + pub offset_x: f32, + pub offset_y: f32, + pub blur_radius: f32, + pub color: Color, +} + // --------------------------------------------------------------------------- // ComputedStyle // --------------------------------------------------------------------------- @@ -860,6 +869,7 @@ pub struct ComputedStyle { pub text_align: TextAlign, pub text_decoration: TextDecoration, pub text_transform: TextTransform, + pub text_shadows: Vec, pub line_height: f32, pub white_space: WhiteSpace, @@ -1015,6 +1025,7 @@ impl Default for ComputedStyle { text_align: TextAlign::Left, text_decoration: TextDecoration::None, text_transform: TextTransform::None, + text_shadows: Vec::new(), line_height: 19.2, // 1.2 * 16 white_space: WhiteSpace::Normal, @@ -1106,6 +1117,7 @@ impl ComputedStyle { let animations = self.animation.animations.capacity() * std::mem::size_of::(); let box_shadows = self.box_shadows.capacity() * std::mem::size_of::(); + let text_shadows = self.text_shadows.capacity() * std::mem::size_of::(); let anim_strings: usize = self .animation .animations @@ -1125,6 +1137,7 @@ impl ComputedStyle { + transitions + animations + box_shadows + + text_shadows + anim_strings + custom_props } @@ -1145,6 +1158,7 @@ fn is_inherited_property(property: &str) -> bool { | "text-align" | "text-decoration" | "text-transform" + | "text-shadow" | "line-height" | "visibility" | "white-space" @@ -1871,6 +1885,59 @@ fn parse_box_shadow_layer( }) } +fn parse_text_shadows( + value: &CssValue, + current_color: Color, + current_font_size: f32, + viewport: (f32, f32), +) -> Vec { + match value { + CssValue::None => return Vec::new(), + CssValue::Keyword(k) if k == "none" => return Vec::new(), + _ => {} + } + + split_shadow_layers(value) + .into_iter() + .filter_map(|layer| { + parse_text_shadow_layer(&layer, current_color, current_font_size, viewport) + }) + .collect() +} + +fn parse_text_shadow_layer( + values: &[CssValue], + current_color: Color, + current_font_size: f32, + viewport: (f32, f32), +) -> Option { + let mut lengths = Vec::new(); + let mut color = None; + + for value in values { + if let Some(c) = resolve_color(value, current_color) { + color = Some(c); + continue; + } + if let Some(px) = shadow_length_px(value, current_font_size, viewport) { + lengths.push(px); + continue; + } + return None; + } + + if lengths.len() < 2 || lengths.len() > 3 { + return None; + } + + Some(TextShadow { + offset_x: lengths[0], + offset_y: lengths[1], + blur_radius: lengths.get(2).copied().unwrap_or(0.0).max(0.0), + color: color.unwrap_or(current_color), + }) +} + fn shadow_length_px(value: &CssValue, current_font_size: f32, viewport: (f32, f32)) -> Option { match value { CssValue::Length(n, unit) => { @@ -2298,6 +2365,11 @@ fn apply_property( }; } + // Text-shadow (inherited) + "text-shadow" => { + style.text_shadows = parse_text_shadows(value, style.color, current_fs, viewport); + } + // Line-height (inherited) "line-height" => match value { CssValue::Keyword(k) if k == "normal" => { @@ -3266,6 +3338,7 @@ fn inherit_property(style: &mut ComputedStyle, property: &str, parent: &Computed "text-align" => style.text_align = parent.text_align, "text-decoration" => style.text_decoration = parent.text_decoration, "text-transform" => style.text_transform = parent.text_transform, + "text-shadow" => style.text_shadows = parent.text_shadows.clone(), "line-height" => style.line_height = parent.line_height, "visibility" => style.visibility = parent.visibility, "white-space" => style.white_space = parent.white_space, @@ -3386,6 +3459,7 @@ fn reset_property_to_initial(style: &mut ComputedStyle, property: &str) { "text-align" => style.text_align = initial.text_align, "text-decoration" => style.text_decoration = initial.text_decoration, "text-transform" => style.text_transform = initial.text_transform, + "text-shadow" => style.text_shadows = initial.text_shadows, "line-height" => style.line_height = initial.line_height, "white-space" => style.white_space = initial.white_space, "background-color" => style.background_color = initial.background_color, @@ -3957,6 +4031,7 @@ fn compute_style_for_element_with_inputs( text_align: parent_style.text_align, text_decoration: parent_style.text_decoration, text_transform: parent_style.text_transform, + text_shadows: parent_style.text_shadows.clone(), line_height: parent_style.line_height, white_space: parent_style.white_space, visibility: parent_style.visibility, @@ -4577,6 +4652,43 @@ mod tests { ); } + #[test] + fn author_text_shadow_parses_and_inherits() { + let (mut doc, _, _, body) = make_doc_with_body(); + let div = doc.create_element("div"); + let span = doc.create_element("span"); + doc.append_child(body, div); + doc.append_child(div, span); + + let ss = Parser::parse( + "div { color: #123456; text-shadow: 2px 3px 4px rgba(0,0,0,.5), 0 1px currentColor; }", + ); + let styled = resolve_styles(&doc, &[ss], (800.0, 600.0)).unwrap(); + let div_node = &styled.children[0].children[0]; + let span_node = &div_node.children[0]; + + assert_eq!(div_node.style.text_shadows.len(), 2); + assert_eq!( + div_node.style.text_shadows[0], + TextShadow { + offset_x: 2.0, + offset_y: 3.0, + blur_radius: 4.0, + color: Color::new(0, 0, 0, 128), + } + ); + assert_eq!( + div_node.style.text_shadows[1], + TextShadow { + offset_x: 0.0, + offset_y: 1.0, + blur_radius: 0.0, + color: Color::rgb(18, 52, 86), + } + ); + assert_eq!(span_node.style.text_shadows, div_node.style.text_shadows); + } + #[test] fn author_font_size_px() { let (mut doc, _, _, body) = make_doc_with_body();