From 237ba356390613bda2995caae2b310dc1caab9cc Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Sun, 26 Jul 2026 21:51:34 +0000 Subject: [PATCH] render: shape atomic generated controls --- crates/obscura-render/src/css.rs | 7 -- crates/obscura-render/src/dom.rs | 136 +++++++++++++++++++++++++++- crates/obscura-render/src/inline.rs | 63 ++++++++++++- crates/obscura-render/src/lib.rs | 10 ++ crates/obscura-render/src/paint.rs | 65 +++++++++---- crates/obscura-render/src/style.rs | 16 ++++ 6 files changed, 271 insertions(+), 26 deletions(-) diff --git a/crates/obscura-render/src/css.rs b/crates/obscura-render/src/css.rs index e24a1be..662617f 100644 --- a/crates/obscura-render/src/css.rs +++ b/crates/obscura-render/src/css.rs @@ -128,7 +128,6 @@ impl Stylesheet { tree: &DomTree, matcher: &mut Matcher, nid: NodeId, - host: &LayoutStyle, props: &HashMap, ) -> (Option, Option) { let build = |rules: &[PseudoRule], matcher: &mut Matcher| { @@ -142,12 +141,6 @@ impl Stylesheet { } matched.sort_unstable_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1))); let mut style = LayoutStyle::default(); - style.color = host.color; - style.font_size = host.font_size; - style.font_weight = host.font_weight.clone(); - style.font_family = host.font_family.clone(); - style.line_height = host.line_height; - style.text_transform = host.text_transform; let mut content = None; for &(_, _, rule) in &matched { let expanded = substitute_vars(&rule.normal_decls, props, 0); diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index d7d8ae4..4bba4bc 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -353,7 +353,7 @@ fn cascade_walk( this_props = std::rc::Rc::new(m); } let (before_pseudo, after_pseudo) = - sheet.pseudo_styles(tree, matcher, id, &style, &this_props); + sheet.pseudo_styles(tree, matcher, id, &this_props); style.before_content = before_pseudo .as_ref() .filter(|pseudo| pseudo.position != Some(taffy::Position::Absolute)) @@ -944,6 +944,116 @@ pub(crate) fn layout_dom_with_web_fonts( } } + // Pseudo-elements inherit the originating element's COMPUTED + // values. They were cascaded beside the host before this + // top-down pass, so inheriting its still-unresolved specified + // values there made `font-size:.875rem` disappear and left a + // positioned generated label at the 16px paint fallback. + // Resolve pseudo-authored relative values against the host, + // then fill every omitted inherited property from the host's + // now-final computed style. + let host_color = style.color; + let host_font_size = style.font_size.unwrap_or(parent_fs); + let host_weight = crate::style::used_font_weight(style); + let host_family = style.font_family.clone(); + let host_line_height = style.line_height; + let host_transform = style.text_transform; + let host_italic = style.font_style_italic; + let host_text_align = style.text_align; + let host_invisible = style.effectively_invisible; + let settle_pseudo = |pseudo: &mut crate::LayoutStyle| { + if let Some(expression) = pseudo.font_size_expression.as_deref() { + pseudo.font_size = crate::style::resolve_contextual_length( + expression, + host_font_size, + root_fs, + vw, + vh, + host_font_size, + ); + } else if let Some(raw) = pseudo.font_size_raw { + pseudo.font_size = Some(match raw { + crate::Dimension::Percent(percent) => host_font_size * percent, + crate::Dimension::Em(value) => host_font_size * value, + dimension => match dimension.resolve( + host_font_size, + root_fs, + vw, + vh, + ) { + crate::Dimension::Px(pixels) => pixels, + _ => host_font_size, + }, + }); + } else if pseudo.font_size.is_none() { + pseudo.font_size = Some(host_font_size); + } + let pseudo_em = pseudo.font_size.unwrap_or(host_font_size); + let weight = crate::style::computed_font_weight( + pseudo.font_weight.as_deref(), + host_weight, + ); + pseudo.font_weight = Some(weight.to_string()); + if pseudo.font_family.is_none() { + pseudo.font_family = host_family.clone(); + } + if let Some(expression) = pseudo.line_height_expression.as_deref() { + if let Some(resolved) = crate::style::resolve_contextual_length( + expression, + pseudo_em, + root_fs, + vw, + vh, + pseudo_em, + ) { + pseudo.line_height = Some( + if crate::style::line_height_expression_is_length(expression) { + crate::LineHeight::Px(resolved) + } else { + crate::LineHeight::Ratio(resolved) + }, + ); + } + } else if let Some(crate::LineHeight::Relative(relative)) = + pseudo.line_height + { + let pixels = match relative { + crate::Dimension::Percent(percent) => pseudo_em * percent, + dimension => match dimension.resolve( + pseudo_em, + root_fs, + vw, + vh, + ) { + crate::Dimension::Px(pixels) => pixels, + _ => pseudo_em, + }, + }; + pseudo.line_height = Some(crate::LineHeight::Px(pixels)); + } else if pseudo.line_height.is_none() { + pseudo.line_height = host_line_height; + } + if pseudo.color.is_none() { + pseudo.color = host_color; + } + if pseudo.text_transform.is_none() { + pseudo.text_transform = host_transform; + } + if pseudo.font_style_italic.is_none() { + pseudo.font_style_italic = host_italic; + } + if pseudo.text_align.is_none() { + pseudo.text_align = host_text_align; + } + pseudo.effectively_invisible = host_invisible; + }; + if let Some(pseudo) = style.before_pseudo.as_deref_mut() { + settle_pseudo(pseudo); + } + if let Some(pseudo) = style.after_pseudo.as_deref_mut() { + settle_pseudo(pseudo); + } + // Containing-block width handed to this element's children is // its own content-box width. A definite content-box width is // already that value; a border-box or auto width includes @@ -4961,4 +5071,28 @@ mod tests { assert_eq!(width("border-cell"), 100.0); } + #[test] + fn positioned_pseudo_inherits_the_hosts_computed_font_metrics() { + let tree = parse_html( + r#" + "#, + ); + let laid = layout_dom(&tree, (1280.0, 720.0)); + let cta = tree.get_element_by_id("cta").unwrap(); + let host = &laid.styles[&cta]; + let pseudo = host.after_pseudo.as_ref().unwrap(); + + assert_eq!(host.font_size, Some(14.0)); + assert_eq!(pseudo.font_size, host.font_size); + assert_eq!(pseudo.font_family, host.font_family); + assert_eq!(pseudo.font_weight, host.font_weight); + assert_eq!(pseudo.line_height, host.line_height); + } + } diff --git a/crates/obscura-render/src/inline.rs b/crates/obscura-render/src/inline.rs index 9dad076..26ef84f 100644 --- a/crates/obscura-render/src/inline.rs +++ b/crates/obscura-render/src/inline.rs @@ -535,6 +535,48 @@ impl TextEngine { self.push_shaped_item(base, spans, collector.clip_fills) } + /// Shape generated text that owns a positioned pseudo box. + /// + /// Positioned `::before`/`::after` boxes do not participate in the taffy + /// tree, but their text still uses the same authored webfonts, variable + /// weight selection, transformations, and glyph rasterizer as an ordinary + /// inline formatting context. The caller measures/finalizes/paints the + /// returned item immediately against the pseudo's resolved content box. + pub(crate) fn push_generated_text( + &mut self, + text: &str, + style: &LayoutStyle, + ) -> Option { + let mut collector = Collector { + last_was_space: true, + clip_fills: Vec::new(), + }; + let (family, weight) = resolve_loaded_font( + style.font_family.as_deref(), + crate::style::used_font_weight(style), + style.font_style_italic.unwrap_or(false), + &self.loaded_families, + ); + let context = base_span_ctx(style, family, weight, &mut collector); + let attrs = SpanAttrs { + weight: context.weight, + italic: context.italic, + underline: context.underline, + color: context.color, + family: context.family, + clip_fill: context.clip_fill, + }; + let mut spans = Vec::new(); + push_text( + text, + context.transform, + &attrs, + &mut spans, + &mut collector, + ); + self.push_shaped_item(style, spans, collector.clip_fills) + } + /// Shared tail of [`try_build`] / [`try_build_run`]: shape the collected /// spans into a cosmic-text buffer under `base`'s font metrics and /// alignment, and store it as a new inline item. @@ -936,7 +978,9 @@ fn is_pure_text_ifc(tree: &DomTree, id: NodeId, styles: &std::collections::HashM // the flex-column stand-ins our UA sheet uses for td/th/center). A real // flex/grid row with inline children is rare and better left to taffy. let flow = style.display == Display::Block - || (style.display == Display::Flex && style.flex_direction == Some(taffy::FlexDirection::Column)); + || style.is_inline_block + || (style.display == Display::Flex + && style.flex_direction == Some(taffy::FlexDirection::Column)); if !flow { return false; } @@ -1342,6 +1386,23 @@ mod tests { assert_eq!(semibold.1, 700); } + #[test] + fn text_only_inline_block_keeps_an_internal_shaping_context() { + let tree = obscura_dom::parse_html( + "ligature_name", + ); + let icon = tree.get_element_by_id("icon").unwrap(); + let mut style = LayoutStyle::default(); + style.display = Display::Inline; + style.is_inline_block = true; + let styles = HashMap::from([(icon, style)]); + + assert!( + is_pure_text_ifc(&tree, icon, &styles), + "atomic inline participation must not disable shaping inside the box" + ); + } + #[test] fn clip_fill_only_for_transparent_clip_text() { let mut s = LayoutStyle::default(); diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index 620caa9..d6782a1 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -804,6 +804,16 @@ pub(crate) fn to_taffy_style(style: &LayoutStyle) -> Style { s.align_content = style.align_content; if let Some(jc) = style.justify_content { s.justify_content = Some(jc); + } else if style.is_inline_block { + // Taffy's inline-box stand-in is a wrapping flex row. `text-align` + // aligns each line of an inline-block's internal formatting context; + // map that inherited value onto the row axis without affecting how + // the atomic box itself participates in its parent's inline flow. + s.justify_content = match style.text_align { + Some(taffy::AlignItems::CENTER) => Some(taffy::JustifyContent::CENTER), + Some(taffy::AlignItems::FLEX_END) => Some(taffy::JustifyContent::FLEX_END), + _ => None, + }; } if let Some(fg) = style.flex_grow { s.flex_grow = fg; diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index e47ba1d..262c71a 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -276,6 +276,7 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) - .flatten() { paint_positioned_pseudo( + &mut laid.text_engine, &mut pixmap, pseudo, &rect, @@ -1745,6 +1746,7 @@ fn split_background_size_components(value: &str) -> Vec<&str> { } fn paint_positioned_pseudo( + text_engine: &mut crate::inline::TextEngine, pixmap: &mut Pixmap, style: &crate::LayoutStyle, containing_block: &crate::Rect, @@ -1816,10 +1818,8 @@ fn paint_positioned_pseudo( paint_linear_gradient(pixmap, &path, &rect, *angle, stops); } if let Some(content) = style.before_content.as_deref().filter(|content| !content.is_empty()) { - let font_size = style.font_size.unwrap_or(16.0); - let bold = crate::style::used_font_weight(style) >= 600; - let text_width = measure_text(content, font_size, bold, style.font_family.as_deref()); - let line_height = crate::inline::used_line_height(style); + let Some(item) = text_engine.push_generated_text(content, style) else { return }; + let (text_width, text_height) = text_engine.measure(item, None); let x = match style.justify_content { Some(taffy::JustifyContent::CENTER) => rect.x + (rect.width - text_width) / 2.0, Some(taffy::JustifyContent::FLEX_END | taffy::JustifyContent::END) => { @@ -1828,23 +1828,14 @@ fn paint_positioned_pseudo( _ => rect.x + style.padding.left, }; let y = match style.align_items { - Some(taffy::AlignItems::CENTER) => rect.y + (rect.height - line_height) / 2.0, + Some(taffy::AlignItems::CENTER) => rect.y + (rect.height - text_height) / 2.0, Some(taffy::AlignItems::FLEX_END | taffy::AlignItems::END) => { - rect.y + rect.height - style.padding.bottom - line_height + rect.y + rect.height - style.padding.bottom - text_height } _ => rect.y + style.padding.top, }; - draw_text( - pixmap, - content, - x, - y, - style.color.unwrap_or([0, 0, 0, 255]), - font_size, - bold, - style.font_family.as_deref(), - Some(visible), - ); + text_engine.finalize(item, (x, y), text_width, Some(visible)); + text_engine.paint_item(item, pixmap, (0.0, 0.0)); } } @@ -2948,6 +2939,46 @@ mod tests { assert!(dark_pixels > 10, "generated attr() text must be painted"); } + #[test] + fn later_positioned_pseudo_opaquely_covers_the_earlier_one() { + let tree = parse_html( + r#""#, + ); + let pixmap = paint_dom(&tree, (140.0, 60.0), None).expect("pixmap"); + let inner = pixmap.pixel(5, 5).expect("inner pixel"); + assert_eq!( + (inner.red(), inner.green(), inner.blue()), + (255, 255, 255), + "::after's opaque background must cover ::before" + ); + let red_pixels = (1..119) + .flat_map(|x| (1..39).map(move |y| (x, y))) + .filter(|&(x, y)| { + let pixel = pixmap.pixel(x, y).unwrap(); + pixel.red() > 180 && pixel.green() < 80 && pixel.blue() < 80 + }) + .count(); + assert_eq!(red_pixels, 0, "::before must not bleed through ::after"); + } + #[test] fn later_element_paints_over_earlier() { // A blue div nested inside a red one: both cover the origin, and blue diff --git a/crates/obscura-render/src/style.rs b/crates/obscura-render/src/style.rs index 4f1fd00..b4aaa5d 100644 --- a/crates/obscura-render/src/style.rs +++ b/crates/obscura-render/src/style.rs @@ -107,6 +107,14 @@ pub fn ua_style(tag: &str) -> LayoutStyle { } else if tag == "a" { style.color = Some([0, 0, 238, 255]); // blue style.underline = Some(true); // UA default: links are underlined + } else if tag == "button" { + // HTML buttons are inline-block controls whose anonymous inner + // content is centered by the browser UA sheet. Keep the atomic outer + // participation separate from the internal text alignment: authored + // display/text-align declarations still cascade over both values. + style.display = Display::Inline; + style.is_inline_block = true; + style.text_align = Some(taffy::AlignItems::CENTER); } else if tag == "input" { // Native text controls are atomic inline-level boxes with their own // platform font and intrinsic border-box dimensions; they do not @@ -3528,6 +3536,14 @@ mod tests { assert_eq!(collapsed.border_collapse, Some(true)); } + #[test] + fn button_ua_style_is_a_centered_atomic_inline_box() { + let button = ua_style("button"); + assert_eq!(button.display, crate::Display::Inline); + assert!(button.is_inline_block); + assert_eq!(button.text_align, Some(taffy::AlignItems::CENTER)); + } + #[test] fn border_none_clears_native_and_per_side_widths() { let input = compute_style("input", Some("border:none"));