diff --git a/crates/obscura-render/src/css.rs b/crates/obscura-render/src/css.rs index fe15c66..e24a1be 100644 --- a/crates/obscura-render/src/css.rs +++ b/crates/obscura-render/src/css.rs @@ -152,15 +152,15 @@ impl Stylesheet { for &(_, _, rule) in &matched { let expanded = substitute_vars(&rule.normal_decls, props, 0); crate::style::apply_declarations(&mut style, &expanded); - if let Some(value) = extract_string_content(&expanded) { - content = Some(value); + if let Some(value) = extract_content(&expanded, tree, nid) { + content = value; } } for &(_, _, rule) in &matched { let expanded = substitute_vars(&rule.important_decls, props, 0); crate::style::apply_declarations(&mut style, &expanded); - if let Some(value) = extract_string_content(&expanded) { - content = Some(value); + if let Some(value) = extract_content(&expanded, tree, nid) { + content = value; } } style.before_content = content; @@ -447,30 +447,44 @@ fn strip_pseudo_element<'a>(selector: &'a str, which: &str) -> Option<&'a str> { None } -/// Pull a plain quoted-string `content` value out of a declaration list -/// (`content: ", "` -> `Some(", ")`). Deliberately narrow: real `content` -/// also accepts `counter()`, `attr()`, `url()` and concatenations of these, -/// none of which apply here (no counters or `attr()`-driven content on the -/// pages this targets), so anything other than a single quoted string is -/// left unhandled rather than guessed at. When `content` is declared more -/// than once (the `content: X; content: X / Y` accessible-alt-text pattern), -/// the last one wins, matching normal CSS declaration order. -fn extract_string_content(decls: &str) -> Option { +/// Return the final valid `content` declaration in a declaration list. +/// +/// The outer option says whether a declaration was found; the inner option is +/// the generated text (`none`/`normal` suppress the pseudo). Along with quoted +/// strings, support the common `attr(name)` form used by component-library +/// buttons and badges. The attribute is resolved against the originating +/// element, as CSS generated content requires. +fn extract_content(decls: &str, tree: &DomTree, nid: NodeId) -> Option> { let mut result = None; - let mut search_from = 0; - while let Some(rel_idx) = decls[search_from..].find("content") { - let idx = search_from + rel_idx; - search_from = idx + "content".len(); - let after = decls[search_from..].trim_start(); - let Some(after_colon) = after.strip_prefix(':') else { continue }; - let after_colon = after_colon.trim_start(); - let Some(quote) = after_colon.chars().next() else { continue }; - if quote != '"' && quote != '\'' { + for raw in crate::style::split_declarations(decls) { + let Some((name, value)) = raw.split_once(':') else { continue }; + if !name.trim().eq_ignore_ascii_case("content") { continue; } - let rest = &after_colon[quote.len_utf8()..]; - let Some(end) = rest.find(quote) else { continue }; - result = Some(unescape_css_string(&rest[..end])); + let value = value.trim(); + if value.eq_ignore_ascii_case("none") || value.eq_ignore_ascii_case("normal") { + result = Some(None); + continue; + } + let parsed = if let Some(quote) = + value.chars().next().filter(|ch| matches!(ch, '"' | '\'')) + { + let rest = &value[quote.len_utf8()..]; + rest.find(quote).map(|end| unescape_css_string(&rest[..end])) + } else if let Some(rest) = value.strip_prefix("attr(") { + rest.find(')').map(|end| { + tree.get_node(nid) + .and_then(|node| { + node.get_attribute(rest[..end].trim()).map(str::to_owned) + }) + .unwrap_or_default() + }) + } else { + None + }; + if let Some(parsed) = parsed { + result = Some(Some(parsed)); + } } result } @@ -1164,6 +1178,22 @@ fn split_media_query_list(query: &str) -> Vec<&str> { mod tests { use super::*; + #[test] + fn generated_content_resolves_host_attributes_and_resets() { + let tree = obscura_dom::parse_html( + r#""#, + ); + let cta = tree.query_selector("#cta").unwrap().unwrap(); + assert_eq!( + extract_content("content:attr(data-label)", &tree, cta), + Some(Some("Get Started".to_string())) + ); + assert_eq!( + extract_content(r#"content:"fallback";content:none"#, &tree, cta), + Some(None) + ); + } + #[test] fn keyframes_extract_only_the_final_animation_state() { let css = r#" diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index 534c379..d7d8ae4 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -356,9 +356,11 @@ fn cascade_walk( sheet.pseudo_styles(tree, matcher, id, &style, &this_props); style.before_content = before_pseudo .as_ref() + .filter(|pseudo| pseudo.position != Some(taffy::Position::Absolute)) .and_then(|pseudo| pseudo.before_content.clone()); style.after_content = after_pseudo .as_ref() + .filter(|pseudo| pseudo.position != Some(taffy::Position::Absolute)) .and_then(|pseudo| pseudo.before_content.clone()); style.before_pseudo = before_pseudo.map(Box::new); style.after_pseudo = after_pseudo.map(Box::new); diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index 16f4520..e47ba1d 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -271,7 +271,10 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) - } } } - if let Some(pseudo) = style.before_pseudo.as_deref() { + for pseudo in [style.before_pseudo.as_deref(), style.after_pseudo.as_deref()] + .into_iter() + .flatten() + { paint_positioned_pseudo( &mut pixmap, pseudo, @@ -1788,12 +1791,16 @@ fn paint_positioned_pseudo( None => Some(rect), }; let Some(visible) = visible else { return }; - let Some(sk_rect) = Rect::from_xywh(visible.x, visible.y, visible.width, visible.height) else { - return; + let path = if style.border_radius > 0.5 { + rounded_rect_path(visible.x, visible.y, visible.width, visible.height, style.border_radius) + } else { + Rect::from_xywh(visible.x, visible.y, visible.width, visible.height).and_then(|rect| { + let mut builder = PathBuilder::new(); + builder.push_rect(rect); + builder.finish() + }) }; - let mut builder = PathBuilder::new(); - builder.push_rect(sk_rect); - let Some(path) = builder.finish() else { return }; + let Some(path) = path else { return }; if let Some(color) = style.background_color { let mut paint = Paint::default(); paint.set_color(Color::from_rgba8(color[0], color[1], color[2], color[3])); @@ -1808,6 +1815,37 @@ fn paint_positioned_pseudo( if let Some((angle, stops)) = &style.background_gradient { 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 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) => { + rect.x + rect.width - style.padding.right - text_width + } + _ => 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::FLEX_END | taffy::AlignItems::END) => { + rect.y + rect.height - style.padding.bottom - line_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), + ); + } } /// Fetch every `` once (seeding `cache` for the paint pass) and record its @@ -2874,6 +2912,42 @@ mod tests { ); } + #[test] + fn paints_positioned_attr_content_over_the_host_background() { + 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), + "the generated box must cover the red host background" + ); + let dark_pixels = (35..85) + .flat_map(|x| (8..32).map(move |y| (x, y))) + .filter(|&(x, y)| { + let pixel = pixmap.pixel(x, y).unwrap(); + pixel.red() < 100 && pixel.green() < 100 && pixel.blue() < 100 + }) + .count(); + assert!(dark_pixels > 10, "generated attr() text must be painted"); + } + #[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 16b4efe..4f1fd00 100644 --- a/crates/obscura-render/src/style.rs +++ b/crates/obscura-render/src/style.rs @@ -409,6 +409,13 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { } } "border" => { + if value.split_whitespace().any(|token| { + token.eq_ignore_ascii_case("none") || token.eq_ignore_ascii_case("hidden") + }) { + style.border = Edges::default(); + style.border_color = None; + return; + } for p in value.split_whitespace() { if let Some(c) = parse_color(p) { style.border_color = Some(c); @@ -418,10 +425,18 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { } } "border-width" => { if let Some(e) = edges(value) { style.border = e; } } - "border-top-width" | "border-top" => set_edge(&mut style.border, Side::Top, px(value)), - "border-right-width" | "border-right" => set_edge(&mut style.border, Side::Right, px(value)), - "border-bottom-width" | "border-bottom" => set_edge(&mut style.border, Side::Bottom, px(value)), - "border-left-width" | "border-left" => set_edge(&mut style.border, Side::Left, px(value)), + "border-top-width" | "border-top" => { + set_edge(&mut style.border, Side::Top, border_side_width(value)) + } + "border-right-width" | "border-right" => { + set_edge(&mut style.border, Side::Right, border_side_width(value)) + } + "border-bottom-width" | "border-bottom" => { + set_edge(&mut style.border, Side::Bottom, border_side_width(value)) + } + "border-left-width" | "border-left" => { + set_edge(&mut style.border, Side::Left, border_side_width(value)) + } "background-color" => style.background_color = parse_color(value), "background" => { // A shorthand resets every omitted background longhand to its @@ -1906,6 +1921,16 @@ fn hsl_to_rgba(h: f32, s: f32, l: f32, a: u8) -> [u8; 4] { enum Side { Top, Right, Bottom, Left } +fn border_side_width(value: &str) -> Option { + if value.split_whitespace().any(|token| { + token.eq_ignore_ascii_case("none") || token.eq_ignore_ascii_case("hidden") + }) { + Some(0.0) + } else { + px(value) + } +} + fn set_edge(edges: &mut Edges, side: Side, v: Option) { let v = match v { Some(v) => v, None => return }; match side { @@ -3503,6 +3528,21 @@ mod tests { assert_eq!(collapsed.border_collapse, Some(true)); } + #[test] + fn border_none_clears_native_and_per_side_widths() { + let input = compute_style("input", Some("border:none")); + assert_eq!(input.border, Edges::default()); + + let side = compute_style( + "div", + Some("border:3px solid red;border-left:none"), + ); + assert_eq!(side.border.top, 3.0); + assert_eq!(side.border.right, 3.0); + assert_eq!(side.border.bottom, 3.0); + assert_eq!(side.border.left, 0.0); + } + #[test] fn item_self_alignment_parses_and_resets() { let aligned = compute_style(