render: shape atomic generated controls

This commit is contained in:
SGavrl committed 2026-07-26 21:51:34 +00:00
1 parent 9ffc071ce0
commit 237ba35639
6 files changed
+271 -26

No files matched your search

-7
View File
@@ -128,7 +128,6 @@ impl Stylesheet {
tree: &DomTree,
matcher: &mut Matcher,
nid: NodeId,
host: &LayoutStyle,
props: &HashMap<String, String>,
) -> (Option<LayoutStyle>, Option<LayoutStyle>) {
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);
+135 -1
View File
@@ -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#"<style>
html { font-size:16px }
button { font-size:.875rem; line-height:1.5 }
button::after {
content:attr(text); position:absolute; inset:1px;
}
</style>
<button id="cta" text="Get Started">Get Started</button>"#,
);
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);
}
}
+62 -1
View File
@@ -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<usize> {
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(
"<span id='icon'>ligature_name</span>",
);
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();
+10
View File
@@ -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;
+48 -17
View File
@@ -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#"<html><head><style>
body { margin:0 }
#cta {
position:relative; width:120px; height:40px; padding:0;
color:transparent; background:black;
}
#cta::before {
content:"before";
position:absolute; inset:1px;
display:flex; align-items:center; justify-content:center;
color:red; background:red;
}
#cta::after {
content:"after";
position:absolute; inset:1px;
display:flex; align-items:center; justify-content:center;
color:blue; background:white;
}
</style></head><body><button id="cta">host</button></body></html>"#,
);
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
+16
View File
@@ -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"));