Expand modern CSS layout and paint parity

This commit is contained in:
SGavrl committed 2026-07-31 06:47:51 +00:00
1 parent 87f6d5a47d
commit af5ae70d31
7 files changed
+4348 -576

No files matched your search

+445 -36
View File
@@ -359,16 +359,17 @@ impl<'a> ContainerQueryEvaluator<'a> {
if !axis_available {
return (ContainerQueryTruth::Unknown, Some(id));
}
let truth = query.condition.as_ref().map_or(
ContainerQueryTruth::True,
|condition| {
let truth =
query
.condition
.as_ref()
.map_or(ContainerQueryTruth::True, |condition| {
evaluate_container_query_expr(
condition,
container,
self.snapshot.root_font_size,
)
},
);
});
return (truth, Some(id));
}
}
@@ -745,7 +746,7 @@ impl Stylesheet {
};
style.color_scheme_dark = host_style.color_scheme_dark;
let inherited_color_scheme_dark = host_style.color_scheme_dark;
let mut content = None;
let mut generated_content = None;
for &(_, _, rule) in &matched {
let expanded = substitute_declarations(&rule.normal_decls, props);
crate::style::apply_color_scheme_declarations_from(
@@ -769,7 +770,7 @@ impl Stylesheet {
&expanded,
);
if let Some(value) = extract_content(&expanded, tree, nid) {
content = value;
generated_content = value;
}
}
for &(_, _, rule) in &matched {
@@ -779,11 +780,14 @@ impl Stylesheet {
&expanded,
);
if let Some(value) = extract_content(&expanded, tree, nid) {
content = value;
generated_content = value;
}
}
style.before_content = content;
if style.before_content.is_some() || style.content_image.is_some() {
style.before_content = generated_content
.as_ref()
.map(|items| generated_content_with_zero_counters(items));
style.generated_content = generated_content;
if style.generated_content.is_some() || style.content_image.is_some() {
Some(style)
} else {
None
@@ -1461,13 +1465,53 @@ fn substitute_var_value(input: &str, props: &HashMap<String, String>, depth: u8)
substitute_var_value(fallback, props, depth + 1)?
}
};
// `var()` substitutes a token sequence, not source text. Insert a
// separator only where reparsing would merge boundary tokens:
// `2px` + `solid` must not become the dimension `2pxsolid`, and `10`
// + `%` must not become a percentage. Do not add unconditional
// whitespace: it is significant around calc()'s `+` and `-`.
if out
.chars()
.next_back()
.zip(replacement.chars().next())
.is_some_and(|(left, right)| css_substitution_boundary_merges(left, right))
{
out.push(' ');
}
out.push_str(&replacement);
if replacement
.chars()
.next_back()
.zip(after[end + 1..].chars().next())
.is_some_and(|(left, right)| css_substitution_boundary_merges(left, right))
{
out.push(' ');
}
rest = &after[end + 1..];
}
out.push_str(rest);
Some(out)
}
fn css_substitution_boundary_merges(left: char, right: char) -> bool {
let name = |ch: char| {
ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' || ch == '\\' || !ch.is_ascii()
};
if left.is_ascii_digit() && right == '-' {
// A number followed by `-` remains two tokens. Separating these would
// incorrectly make `calc(var(--n)- 1px)` satisfy calc's whitespace
// requirement.
return false;
}
(name(left) && name(right))
|| ((left.is_ascii_digit() || left == '.')
&& (right == '.' || right == '%' || name(right)))
|| (matches!(left, '#' | '@') && name(right))
|| (!left.is_ascii_digit() && name(left) && right == '(')
|| (left == '+' && (right.is_ascii_digit() || right == '.'))
|| (left == '/' && right == '*')
}
/// If `selector`'s rightmost compound is the pseudo-element `which`
/// (`"before"` or `"after"`, matching either the modern `::` or legacy `:`
/// form), return everything before it, trimmed. Only a trailing
@@ -1493,7 +1537,11 @@ fn strip_pseudo_element<'a>(selector: &'a str, which: &str) -> Option<&'a str> {
/// 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<Option<String>> {
fn extract_content(
decls: &str,
tree: &DomTree,
nid: NodeId,
) -> Option<Option<Vec<crate::GeneratedContentItem>>> {
let mut result = None;
for raw in crate::style::split_declarations(decls) {
let Some((name, value)) = raw.split_once(':') else { continue };
@@ -1505,22 +1553,7 @@ fn extract_content(decls: &str, tree: &DomTree, nid: NodeId) -> Option<Option<St
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
};
let parsed = parse_generated_content_items(value, tree, nid);
if let Some(parsed) = parsed {
result = Some(Some(parsed));
} else if value
@@ -1539,6 +1572,289 @@ fn extract_content(decls: &str, tree: &DomTree, nid: NodeId) -> Option<Option<St
result
}
fn generated_content_with_zero_counters(items: &[crate::GeneratedContentItem]) -> String {
let mut text = String::new();
for item in items {
match item {
crate::GeneratedContentItem::Text(value) => text.push_str(value),
crate::GeneratedContentItem::Counter { style, .. } => {
text.push_str(&format_counter_value(0, *style));
}
crate::GeneratedContentItem::Counters {
style,
separator: _,
..
} => text.push_str(&format_counter_value(0, *style)),
}
}
text
}
fn parse_generated_content_items(
value: &str,
tree: &DomTree,
nid: NodeId,
) -> Option<Vec<crate::GeneratedContentItem>> {
let mut items = Vec::new();
let mut rest = value.trim();
while !rest.is_empty() {
rest = rest.trim_start();
let first = rest.chars().next()?;
if matches!(first, '"' | '\'') {
let (raw, tail) = take_css_quoted(rest)?;
items.push(crate::GeneratedContentItem::Text(unescape_css_string(raw)));
rest = tail;
continue;
}
let name_end = rest
.char_indices()
.find_map(|(index, ch)| (!is_css_ident_char(ch)).then_some(index))
.unwrap_or(rest.len());
let name = &rest[..name_end];
let after_name = rest[name_end..].trim_start();
if !after_name.starts_with('(') {
// Quote-control keywords are valid generated-content items, but
// they do not contribute text in the current renderer.
if matches!(
name.to_ascii_lowercase().as_str(),
"open-quote" | "close-quote" | "no-open-quote" | "no-close-quote"
) {
rest = after_name;
continue;
}
return None;
}
let (arguments, tail) = take_css_function_arguments(after_name)?;
match name.to_ascii_lowercase().as_str() {
"attr" => {
let attribute = arguments
.split_whitespace()
.next()
.filter(|name| !name.is_empty())?;
let value = tree
.get_node(nid)
.and_then(|node| node.get_attribute(attribute).map(str::to_owned))
.unwrap_or_default();
items.push(crate::GeneratedContentItem::Text(value));
}
"counter" => {
let arguments = split_function_arguments(arguments);
let name = arguments.first()?.trim();
if !valid_generated_counter_name(name) || arguments.len() > 2 {
return None;
}
let style = match arguments.get(1) {
Some(style) => parse_generated_counter_style(style.trim())?,
None => crate::GeneratedCounterStyle::default(),
};
items.push(crate::GeneratedContentItem::Counter {
name: name.to_string(),
style,
});
}
"counters" => {
let arguments = split_function_arguments(arguments);
if !(2..=3).contains(&arguments.len()) {
return None;
}
let name = arguments[0].trim();
if !valid_generated_counter_name(name) {
return None;
}
let (separator, separator_tail) = take_css_quoted(arguments[1].trim())?;
if !separator_tail.trim().is_empty() {
return None;
}
let style = match arguments.get(2) {
Some(style) => parse_generated_counter_style(style.trim())?,
None => crate::GeneratedCounterStyle::default(),
};
items.push(crate::GeneratedContentItem::Counters {
name: name.to_string(),
separator: unescape_css_string(separator),
style,
});
}
_ => return None,
}
rest = tail;
}
(!items.is_empty()).then_some(items)
}
fn is_css_ident_char(ch: char) -> bool {
ch.is_alphanumeric() || matches!(ch, '-' | '_' | '\\')
}
fn take_css_quoted(value: &str) -> Option<(&str, &str)> {
let quote = value.chars().next().filter(|ch| matches!(ch, '"' | '\''))?;
let mut escaped = false;
for (offset, ch) in value[quote.len_utf8()..].char_indices() {
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == quote {
let end = quote.len_utf8() + offset;
return Some((
&value[quote.len_utf8()..end],
&value[end + quote.len_utf8()..],
));
}
}
None
}
fn take_css_function_arguments(value: &str) -> Option<(&str, &str)> {
let mut depth = 0usize;
let mut quote = None;
let mut escaped = false;
for (offset, ch) in value.char_indices() {
if let Some(open) = quote {
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == open {
quote = None;
}
continue;
}
match ch {
'"' | '\'' => quote = Some(ch),
'(' => depth += 1,
')' => {
depth = depth.checked_sub(1)?;
if depth == 0 {
return Some((&value[1..offset], &value[offset + 1..]));
}
}
_ => {}
}
}
None
}
fn split_function_arguments(value: &str) -> Vec<&str> {
let mut result = Vec::new();
let mut start = 0;
let mut depth = 0usize;
let mut quote = None;
let mut escaped = false;
for (offset, ch) in value.char_indices() {
if let Some(open) = quote {
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == open {
quote = None;
}
continue;
}
match ch {
'"' | '\'' => quote = Some(ch),
'(' => depth += 1,
')' => depth = depth.saturating_sub(1),
',' if depth == 0 => {
result.push(value[start..offset].trim());
start = offset + 1;
}
_ => {}
}
}
result.push(value[start..].trim());
result
}
fn valid_generated_counter_name(name: &str) -> bool {
!name.is_empty() && name.chars().all(is_css_ident_char)
}
fn parse_generated_counter_style(value: &str) -> Option<crate::GeneratedCounterStyle> {
Some(match value.to_ascii_lowercase().as_str() {
"decimal" => crate::GeneratedCounterStyle::Decimal,
"decimal-leading-zero" => crate::GeneratedCounterStyle::DecimalLeadingZero,
"lower-alpha" | "lower-latin" => crate::GeneratedCounterStyle::LowerAlpha,
"upper-alpha" | "upper-latin" => crate::GeneratedCounterStyle::UpperAlpha,
"lower-roman" => crate::GeneratedCounterStyle::LowerRoman,
"upper-roman" => crate::GeneratedCounterStyle::UpperRoman,
_ => return None,
})
}
pub(crate) fn format_counter_value(value: i32, style: crate::GeneratedCounterStyle) -> String {
match style {
crate::GeneratedCounterStyle::Decimal => value.to_string(),
crate::GeneratedCounterStyle::DecimalLeadingZero if (-9..=9).contains(&value) => {
if value < 0 {
format!("-{:02}", value.unsigned_abs())
} else {
format!("{value:02}")
}
}
crate::GeneratedCounterStyle::DecimalLeadingZero => value.to_string(),
crate::GeneratedCounterStyle::LowerAlpha => alpha_counter(value, false),
crate::GeneratedCounterStyle::UpperAlpha => alpha_counter(value, true),
crate::GeneratedCounterStyle::LowerRoman => roman_counter(value, false),
crate::GeneratedCounterStyle::UpperRoman => roman_counter(value, true),
}
}
fn alpha_counter(value: i32, uppercase: bool) -> String {
if value <= 0 {
return value.to_string();
}
let mut value = value as u32;
let mut result = Vec::new();
while value > 0 {
value -= 1;
result.push((b'a' + (value % 26) as u8) as char);
value /= 26;
}
result.reverse();
let result: String = result.into_iter().collect();
if uppercase {
result.to_ascii_uppercase()
} else {
result
}
}
fn roman_counter(value: i32, uppercase: bool) -> String {
if !(1..=3999).contains(&value) {
return value.to_string();
}
let mut value = value;
let mut result = String::new();
for &(amount, numeral) in &[
(1000, "M"),
(900, "CM"),
(500, "D"),
(400, "CD"),
(100, "C"),
(90, "XC"),
(50, "L"),
(40, "XL"),
(10, "X"),
(9, "IX"),
(5, "V"),
(4, "IV"),
(1, "I"),
] {
while value >= amount {
value -= amount;
result.push_str(numeral);
}
}
if uppercase {
result
} else {
result.to_ascii_lowercase()
}
}
/// Decode CSS string escapes: `\` followed by 1-6 hex digits is a Unicode
/// code point (`\200B` -> U+200B ZERO WIDTH SPACE, ubiquitous in generated
/// `content` for accessible section-edit-link brackets and similar), with a
@@ -2892,7 +3208,9 @@ mod tests {
let cta = tree.query_selector("#cta").unwrap().unwrap();
assert_eq!(
extract_content("content:attr(data-label)", &tree, cta),
Some(Some("Get Started".to_string()))
Some(Some(vec![crate::GeneratedContentItem::Text(
"Get Started".to_string()
)]))
);
assert_eq!(
extract_content(r#"content:"fallback";content:none"#, &tree, cta),
@@ -2900,6 +3218,44 @@ mod tests {
);
}
#[test]
fn generated_content_parses_counter_items_and_styles() {
let tree = obscura_dom::parse_html(r#"<span id="line"></span>"#);
let line = tree.query_selector("#line").unwrap().unwrap();
assert_eq!(
extract_content(
r#"content:"[" counters(section, ".", upper-roman) "] " counter(line)"#,
&tree,
line,
),
Some(Some(vec![
crate::GeneratedContentItem::Text("[".to_string()),
crate::GeneratedContentItem::Counters {
name: "section".to_string(),
separator: ".".to_string(),
style: crate::GeneratedCounterStyle::UpperRoman,
},
crate::GeneratedContentItem::Text("] ".to_string()),
crate::GeneratedContentItem::Counter {
name: "line".to_string(),
style: crate::GeneratedCounterStyle::Decimal,
},
]))
);
assert_eq!(
format_counter_value(27, crate::GeneratedCounterStyle::LowerAlpha),
"aa"
);
assert_eq!(
format_counter_value(14, crate::GeneratedCounterStyle::UpperRoman),
"XIV"
);
assert_eq!(
format_counter_value(-4, crate::GeneratedCounterStyle::DecimalLeadingZero),
"-04"
);
}
#[test]
fn keyframes_retain_every_animation_offset() {
let css = r#"
@@ -3231,17 +3587,13 @@ mod tests {
let css = ".card { display:block; @media (max-width: 950px) { width:100%; } }";
let narrow = parse_stylesheet_for_viewport(css, (900.0, 1000.0));
let wide = parse_stylesheet_for_viewport(css, (1280.0, 720.0));
assert!(
narrow
assert!(narrow
.iter()
.any(|(selector, declarations)| selector == ".card"
&& declarations.contains("width:100%"))
);
assert!(
!wide
&& declarations.contains("width:100%")));
assert!(!wide
.iter()
.any(|(_, declarations)| declarations.contains("width:100%"))
);
.any(|(_, declarations)| declarations.contains("width:100%")));
}
#[test]
@@ -3487,6 +3839,63 @@ mod tests {
);
}
#[test]
fn var_substitution_preserves_neighboring_token_boundaries() {
let props = HashMap::from([
("--stroke".to_string(), "2px".to_string()),
("--amount".to_string(), "10".to_string()),
]);
assert_eq!(
substitute_var_value("var(--stroke)solid", &props, 0).as_deref(),
Some("2px solid")
);
assert_eq!(
substitute_var_value("calc(var(--stroke)*3)", &props, 0).as_deref(),
Some("calc(2px*3)"),
"punctuation adjacency must not gain calc-significant whitespace"
);
assert_eq!(
substitute_var_value("calc(var(--amount)- 2px)", &props, 0).as_deref(),
Some("calc(10- 2px)"),
"substitution must not make an invalid unspaced calc minus valid"
);
assert_eq!(
substitute_var_value("+var(--amount)", &props, 0).as_deref(),
Some("+ 10"),
"a delim plus followed by a number must not become a signed number token"
);
assert_eq!(
substitute_var_value("var(--amount)%", &props, 0).as_deref(),
Some("10 %"),
"a number token followed by a percent delimiter is not a percentage token"
);
let tree = obscura_dom::parse_html(r#"<div id="target"></div>"#);
let css = r#"
#target {
--stroke:2px;
--ink:#123456;
border:var(--stroke)solid var(--ink);
height:calc(var(--stroke)*3);
}
"#;
let sheet = Stylesheet::parse(&tree, &[css.to_string()]);
let target = tree.get_element_by_id("target").unwrap();
let (style, _) =
apply_registered_property_test_style(&sheet, &tree, target, &HashMap::new());
assert_eq!(
style.border,
crate::Edges {
top: 2.0,
right: 2.0,
bottom: 2.0,
left: 2.0,
}
);
assert_eq!(style.border_color, Some([0x12, 0x34, 0x56, 0xff]));
assert_eq!(style.height, crate::Dimension::Px(6.0));
}
#[test]
fn wildcard_duplicate_and_invalid_property_registrations_are_bounded() {
let tree = obscura_dom::parse_html(
File diff suppressed because it is too large. Load diff
+231 -78
View File
@@ -27,11 +27,10 @@ use obscura_dom::tree::{DomTree, NodeId};
use crate::{Dimension, Display, LayoutStyle, Rect, TextTransform};
// Bundled faces. Chrome on this class of host renders `sans-serif` and the
// ubiquitous Arial/Helvetica/system-ui stacks as Liberation Sans, `serif` as
// Liberation Serif, and `monospace` as Liberation Mono. Matching those keeps
// text metrics (advance widths, wrapping, line positions) aligned with Chromium
// instead of drifting ~12% wider on DejaVu's wider glyphs. DejaVu Sans is kept
// only as a last-resort fallback for glyphs the Liberation faces lack.
// ubiquitous Arial/Helvetica stacks as Liberation Sans, `system-ui` as DejaVu
// Sans, `serif` as Liberation Serif, and `monospace` as Liberation Mono.
// Matching those keeps text metrics (advance widths, wrapping, line positions)
// aligned with Chromium instead of drifting between unrelated host faces.
static SANS_R: &[u8] = include_bytes!("../assets/liberation-sans.ttf");
static SANS_B: &[u8] = include_bytes!("../assets/liberation-sans-bold.ttf");
static SANS_O: &[u8] = include_bytes!("../assets/liberation-sans-oblique.ttf");
@@ -44,46 +43,80 @@ static MONO_R: &[u8] = include_bytes!("../assets/liberation-mono.ttf");
static MONO_B: &[u8] = include_bytes!("../assets/liberation-mono-bold.ttf");
static MONO_O: &[u8] = include_bytes!("../assets/liberation-mono-oblique.ttf");
static MONO_BO: &[u8] = include_bytes!("../assets/liberation-mono-boldoblique.ttf");
static FALLBACK: &[u8] = include_bytes!("../assets/dejavu-sans.ttf");
static SYSTEM_R: &[u8] = include_bytes!("../assets/dejavu-sans.ttf");
static SYSTEM_B: &[u8] = include_bytes!("../assets/dejavu-sans-bold.ttf");
#[cfg(test)]
static FALLBACK: &[u8] = SYSTEM_R;
const FAMILY: &str = "Liberation Sans";
const SERIF_FAMILY: &str = "Liberation Serif";
const MONO_FAMILY: &str = "Liberation Mono";
const SYSTEM_FAMILY: &str = "DejaVu Sans";
/// Map a CSS `font-family` list to a bundled face the way Chromium resolves the
/// generic families on this host: a monospace/code stack -> Liberation Mono, a
/// serif stack -> Liberation Serif, everything else (sans-serif, Arial,
/// Helvetica, system-ui, named sans webfonts, ...) -> Liberation Sans. The first
/// family whose category is recognizable wins, matching CSS fallback order.
/// generic families on this host. Chromium's Linux `system-ui` resolves to
/// DejaVu Sans, while `sans-serif`/Arial/Helvetica resolve to Liberation Sans.
/// The first recognizable family wins, matching CSS fallback order.
fn resolve_font_family(fam: Option<&str>) -> &'static str {
let Some(f) = fam else { return FAMILY };
for tok in f.split(',') {
let t = tok.trim().trim_matches(|c| c == '"' || c == '\'').trim();
if t.is_empty() {
continue;
}
if t == "monospace" || t.contains("mono") || t.contains("courier")
|| t.contains("consol") || t == "menlo" || t == "monaco" || t == "code"
{
return MONO_FAMILY;
}
if t == "serif" || t == "georgia" || t.contains("times") || t == "cambria"
|| t.contains("garamond") || t.contains("liberation serif") || t == "roman"
{
return SERIF_FAMILY;
}
if t == "sans-serif" || t.contains("sans") || t == "arial" || t == "helvetica"
|| t == "helvetica neue" || t == "system-ui" || t == "-apple-system"
|| t == "roboto" || t == "segoe ui" || t == "inter" || t == "verdana"
|| t == "tahoma" || t == "ui-sans-serif"
{
return FAMILY;
if let Some(family) = bundled_family_for_css_token(tok) {
return family;
}
// Unrecognized named webfont: keep scanning for a generic fallback.
}
FAMILY
}
fn bundled_family_for_css_token(token: &str) -> Option<&'static str> {
let token = token
.trim()
.trim_matches(|c| c == '"' || c == '\'')
.trim()
.to_ascii_lowercase();
if token.is_empty() {
return None;
}
if token == "system-ui" || token == "ui-sans-serif" {
return Some(SYSTEM_FAMILY);
}
if token == "monospace"
|| token.contains("mono")
|| token.contains("courier")
|| token.contains("consol")
|| token == "menlo"
|| token == "monaco"
|| token == "code"
{
return Some(MONO_FAMILY);
}
if token == "serif"
|| token == "georgia"
|| token.contains("times")
|| token == "cambria"
|| token.contains("garamond")
|| token.contains("liberation serif")
|| token == "roman"
{
return Some(SERIF_FAMILY);
}
if token == "sans-serif"
|| token.contains("sans")
|| token == "arial"
|| token == "helvetica"
|| token == "helvetica neue"
|| token == "-apple-system"
|| token == "roboto"
|| token == "segoe ui"
|| token == "inter"
|| token == "verdana"
|| token == "tahoma"
{
return Some(FAMILY);
}
None
}
#[derive(Clone)]
struct LoadedFamily {
faces: Vec<LoadedFace>,
@@ -112,6 +145,7 @@ struct ResolvedFont {
family: Arc<str>,
font_id: Option<cosmic_text::fontdb::ID>,
metrics: FaceMetrics,
synthetic_italic: bool,
}
#[derive(Clone)]
@@ -130,11 +164,32 @@ fn resolve_loaded_font(
) -> ResolvedFont {
if let Some(stack) = fam {
for token in stack.split(',') {
let name = token
.trim()
.trim_matches(|c| c == '"' || c == '\'')
.trim();
if let Some(family) = loaded.get(&name.to_ascii_lowercase()) {
let name = token.trim().trim_matches(|c| c == '"' || c == '\'').trim();
let family = loaded.get(&name.to_ascii_lowercase()).or_else(|| {
bundled_family_for_css_token(name)
.and_then(|family| loaded.get(&family.to_ascii_lowercase()))
});
if let Some(resolved) = family
.and_then(|family| select_loaded_face(family, requested_weight, requested_italic))
{
return resolved;
}
}
}
let fallback = resolve_font_family(fam);
ResolvedFont {
family: Arc::from(fallback),
font_id: None,
metrics: bundled_face_metrics(fallback),
synthetic_italic: false,
}
}
fn select_loaded_face(
family: &LoadedFamily,
requested_weight: u16,
requested_italic: bool,
) -> Option<ResolvedFont> {
let exact_style: Vec<_> = family
.faces
.iter()
@@ -150,39 +205,28 @@ fn resolve_loaded_font(
.copied()
.find(|face| (face.min_weight..=face.max_weight).contains(&requested_weight))
{
// The named-family matcher uses fontdb's default weight for
// this resource, while a variable face commonly advertises
// `100 900` in CSS. Preserve the descriptor-selected file
// and its database weight; the authored coordinate enters
// the canonical axis tuple below.
return ResolvedFont {
// The named-family matcher uses fontdb's default weight for this
// resource, while a variable face commonly advertises `100 900` in
// CSS. Preserve the descriptor-selected file and its database weight;
// the authored coordinate enters the canonical axis tuple below.
return Some(ResolvedFont {
family: Arc::clone(&face.name),
font_id: face.font_id,
metrics: face.metrics,
};
synthetic_italic: requested_italic && !face.italic,
});
}
let available: Vec<_> =
candidates.iter().map(|face| face.min_weight).collect();
let available: Vec<_> = candidates.iter().map(|face| face.min_weight).collect();
let matched = match_font_weight(requested_weight, &available);
if let Some(face) = candidates
candidates
.into_iter()
.find(|face| face.min_weight == matched)
{
return ResolvedFont {
.map(|face| ResolvedFont {
family: Arc::clone(&face.name),
font_id: face.font_id,
metrics: face.metrics,
};
}
}
}
}
let fallback = resolve_font_family(fam);
ResolvedFont {
family: Arc::from(fallback),
font_id: None,
metrics: bundled_face_metrics(fallback),
}
synthetic_italic: requested_italic && !face.italic,
})
}
/// CSS Fonts' asymmetric missing-weight search. In particular, 600 selects
@@ -238,6 +282,7 @@ fn bundled_face_metrics(family: &str) -> FaceMetrics {
let (ascent, descent, line_gap) = match family {
SERIF_FAMILY => (1825.0, 443.0, 87.0),
MONO_FAMILY => (1705.0, 615.0, 0.0),
SYSTEM_FAMILY => (1901.0, 483.0, 0.0),
_ => (1854.0, 434.0, 67.0),
};
FaceMetrics {
@@ -580,11 +625,8 @@ impl ReplacedItem {
.as_deref()
.map_or(false, |expression| expression.contains('%'))
};
let intrinsic_ratio = if width.is_finite()
&& height.is_finite()
&& width > 0.0
&& height > 0.0
{
let intrinsic_ratio =
if width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0 {
width / height
} else {
1.0
@@ -740,10 +782,8 @@ impl TextEngine {
let mut db = cosmic_text::fontdb::Database::new();
let mut declarations = Vec::new();
for bytes in [
SANS_R, SANS_B, SANS_O, SANS_BO,
SERIF_R, SERIF_B, SERIF_O, SERIF_BO,
MONO_R, MONO_B, MONO_O, MONO_BO,
FALLBACK,
SANS_R, SANS_B, SANS_O, SANS_BO, SERIF_R, SERIF_B, SERIF_O, SERIF_BO, MONO_R, MONO_B,
MONO_O, MONO_BO, SYSTEM_R, SYSTEM_B,
] {
for id in db.load_font_source(cosmic_text::fontdb::Source::Binary(Arc::new(bytes))) {
declarations.push((id, None, None, None));
@@ -936,6 +976,7 @@ impl TextEngine {
font_id: context.font_id,
variations: context.variations.clone(),
italic: context.italic,
synthetic_italic: context.synthetic_italic,
underline: context.underline,
color: context.color,
family: context.family,
@@ -977,9 +1018,7 @@ impl TextEngine {
}
}
}
if spans
.iter()
.all(|(text, _)| {
if spans.iter().all(|(text, _)| {
text.is_empty()
|| (matches!(
white_space,
@@ -988,8 +1027,7 @@ impl TextEngine {
| crate::WhiteSpace::PreLine
) && text.trim().is_empty()
&& !text.contains('\n'))
})
{
}) {
return None;
}
@@ -1208,6 +1246,7 @@ struct SpanAttrs {
font_id: Option<cosmic_text::fontdb::ID>,
variations: Option<Arc<FontVariations>>,
italic: bool,
synthetic_italic: bool,
underline: bool,
color: [u8; 4],
family: Arc<str>,
@@ -1234,7 +1273,14 @@ impl SpanAttrs {
if let Some(font_id) = self.font_id {
a = a.font_id(font_id);
}
a = a.style(if self.italic { Style::Italic } else { Style::Normal });
a = a.style(if self.italic {
Style::Italic
} else {
Style::Normal
});
if self.synthetic_italic {
a = a.cache_key_flags(CacheKeyFlags::FAKE_ITALIC);
}
if self.letter_spacing.is_finite() && self.letter_spacing != 0.0 {
a = a.letter_spacing(self.letter_spacing / self.font_size.max(1.0));
}
@@ -1280,6 +1326,7 @@ struct SpanCtx {
font_metrics: FaceMetrics,
variations: Option<Arc<FontVariations>>,
italic: bool,
synthetic_italic: bool,
underline: bool,
transform: TextTransform,
white_space: crate::WhiteSpace,
@@ -1345,6 +1392,7 @@ fn base_span_ctx(
font_metrics: font.metrics,
variations,
italic: base.font_style_italic.unwrap_or(false),
synthetic_italic: font.synthetic_italic,
underline: base.underline.unwrap_or(false),
transform: base.text_transform.unwrap_or(TextTransform::None),
white_space: base.white_space.unwrap_or_default(),
@@ -1401,6 +1449,7 @@ fn collect_node_spans(
font_id: ctx.font_id,
variations: ctx.variations.clone(),
italic: ctx.italic,
synthetic_italic: ctx.synthetic_italic,
underline: ctx.underline,
color: ctx.color,
family: Arc::clone(&ctx.family),
@@ -1422,7 +1471,9 @@ fn collect_node_spans(
return;
}
if elem.local.as_ref() == "br" {
out.push(("\n".to_string(), SpanAttrs {
out.push((
"\n".to_string(),
SpanAttrs {
font_size: ctx.font_size,
line_height: ctx.line_height,
letter_spacing: ctx.letter_spacing,
@@ -1432,11 +1483,13 @@ fn collect_node_spans(
font_id: ctx.font_id,
variations: ctx.variations.clone(),
italic: ctx.italic,
synthetic_italic: ctx.synthetic_italic,
underline: ctx.underline,
color: ctx.color,
family: Arc::clone(&ctx.family),
clip_fill: ctx.clip_fill,
}));
},
));
c.last_was_space = true;
return;
}
@@ -1471,6 +1524,7 @@ fn collect_node_spans(
family: Arc::clone(&ctx.family),
font_id: ctx.font_id,
metrics: ctx.font_metrics,
synthetic_italic: ctx.synthetic_italic,
});
let variations = style
.map(|style| resolved_font_variations(style, &font))
@@ -1497,6 +1551,7 @@ fn collect_node_spans(
font_metrics: font.metrics,
variations,
italic: ctx.italic || style.and_then(|s| s.font_style_italic).unwrap_or(false),
synthetic_italic: font.synthetic_italic,
// Underline propagates in: an ancestor's underline covers
// descendant text; an element only sets its own via CSS.
underline: ctx.underline || style.and_then(|s| s.underline).unwrap_or(false),
@@ -2066,13 +2121,20 @@ impl TextEngine {
let out_r = sr + (dst.red() as u32 * inv / 255);
let out_g = sg + (dst.green() as u32 * inv / 255);
let out_b = sb + (dst.blue() as u32 * inv / 255);
pixels[idx] = tiny_skia::PremultipliedColorU8::from_rgba(out_r as u8, out_g as u8, out_b as u8, out_a as u8)
pixels[idx] = tiny_skia::PremultipliedColorU8::from_rgba(
out_r as u8,
out_g as u8,
out_b as u8,
out_a as u8,
)
.unwrap_or(dst);
};
let explicit_variations = metadata_variation(glyph.metadata)
.and_then(|index| item.variation_sets.get(index))
.map(Arc::clone);
let effective_variations = glyph.font_is_variable.then(|| {
let effective_variations = glyph
.font_is_variable
.then(|| {
variable_swash.effective_variations(
font_system,
physical.cache_key.font_id,
@@ -2081,7 +2143,8 @@ impl TextEngine {
glyph.font_italic_axis,
explicit_variations,
)
}).flatten();
})
.flatten();
if let Some(variations) = effective_variations {
variable_swash.with_pixels(
font_system,
@@ -2159,6 +2222,92 @@ mod tests {
const RED: [u8; 4] = [255, 0, 0, 255];
const BLUE: [u8; 4] = [0, 0, 255, 255];
#[test]
fn system_ui_resolves_in_stack_order_to_chromium_linux_face() {
let stack = "system-ui, -apple-system, \"Segoe UI\", Roboto, \
\"Helvetica Neue\", \"Noto Sans\", \"Liberation Sans\", \
Arial, sans-serif";
assert_eq!(resolve_font_family(Some(stack)), SYSTEM_FAMILY);
let mut engine = TextEngine::new();
let system = resolve_loaded_font(Some(stack), 600, false, &engine.loaded_families);
assert_eq!(system.family.as_ref(), SYSTEM_FAMILY);
let face = engine
.font_system
.db()
.face(system.font_id.expect("bundled system-ui face"))
.expect("system-ui database face");
assert_eq!(
face.weight.0, 700,
"CSS weight 600 selects DejaVu Sans Bold just as Chromium does"
);
let italic = resolve_loaded_font(Some("system-ui"), 400, true, &engine.loaded_families);
let italic_face = engine
.font_system
.db()
.face(italic.font_id.expect("bundled system-ui italic fallback"))
.expect("system-ui italic fallback database face");
assert_eq!(italic_face.weight.0, 400);
assert_eq!(
italic_face.style,
cosmic_text::fontdb::Style::Normal,
"Chromium synthesizes system-ui italic from DejaVu Sans regular on this host"
);
let bold_italic =
resolve_loaded_font(Some("system-ui"), 700, true, &engine.loaded_families);
let bold_italic_face = engine
.font_system
.db()
.face(
bold_italic
.font_id
.expect("bundled system-ui bold italic fallback"),
)
.expect("system-ui bold italic fallback database face");
assert_eq!(bold_italic_face.weight.0, 700);
assert_eq!(
bold_italic_face.style,
cosmic_text::fontdb::Style::Normal,
"Chromium synthesizes system-ui bold italic from DejaVu Sans Bold on this host"
);
let tree = obscura_dom::parse_html("<p id='copy'>Italic system UI</p>");
let copy = tree.get_element_by_id("copy").unwrap();
let mut style = LayoutStyle::default();
style.display = Display::Block;
style.font_family = Some("system-ui".to_string());
style.font_style_italic = Some(true);
style.font_size = Some(32.0);
let item = engine
.try_build(&tree, copy, &HashMap::from([(copy, style)]))
.expect("system-ui italic shapes");
engine.measure(item, Some(400.0));
let glyph = engine.items[item]
.buffer
.layout_runs()
.next()
.and_then(|run| run.glyphs.first())
.expect("system-ui italic glyph");
assert!(
glyph
.physical((0.0, 0.0), 1.0)
.cache_key
.flags
.contains(CacheKeyFlags::FAKE_ITALIC),
"the normal DejaVu resource must retain the requested synthetic slant"
);
let arial = resolve_loaded_font(
Some("Arial, sans-serif"),
600,
false,
&engine.loaded_families,
);
assert_eq!(arial.family.as_ref(), FAMILY);
}
#[test]
fn normal_line_height_grid_fits_each_face_metric() {
// Values measured from Chromium 145 using the same bundled Linux
@@ -2540,6 +2689,7 @@ mod tests {
font_id: None,
variations: Some(Arc::new(variations)),
italic: false,
synthetic_italic: false,
underline: true,
color: [1, 2, 3, 255],
family: Arc::from(FAMILY),
@@ -2569,6 +2719,7 @@ mod tests {
family: Arc::from(FAMILY),
font_id: None,
metrics: bundled_face_metrics(FAMILY),
synthetic_italic: false,
};
assert!(resolved_font_variations(&style, &static_font).is_none());
let styles = HashMap::from([(copy, style)]);
@@ -2598,6 +2749,7 @@ mod tests {
family: Arc::from(FAMILY),
font_id: None,
metrics: bundled_face_metrics(FAMILY),
synthetic_italic: false,
};
assert!(resolved_font_variations(&style, &font).is_none());
}
@@ -2614,6 +2766,7 @@ mod tests {
font_id: None,
variations: None,
italic: false,
synthetic_italic: false,
underline: false,
color: [0, 0, 0, 255],
family: Arc::from(FAMILY),
+213 -24
View File
@@ -235,6 +235,65 @@ impl BorderRadius {
}
}
/// One axis of a CSS `background-position`.
///
/// CSS positions combine an absolute offset with a percentage of the space
/// left after sizing the background image. Keeping both terms is necessary
/// for sprite sheets: `-24px` is an offset from the start edge, while
/// `right 10px` is equivalent to `100% - 10px`.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct BackgroundPositionAxis {
length: f32,
percentage: f32,
}
impl BackgroundPositionAxis {
pub const fn pixels(length: f32) -> Self {
Self {
length,
percentage: 0.0,
}
}
pub const fn percentage(percentage: f32) -> Self {
Self {
length: 0.0,
percentage,
}
}
pub const fn length_percentage(length: f32, percentage: f32) -> Self {
Self { length, percentage }
}
pub const fn from_end_offset(offset: Self) -> Self {
Self {
length: -offset.length,
percentage: 1.0 - offset.percentage,
}
}
pub fn resolve(self, leftover_space: f32) -> f32 {
self.length + self.percentage * leftover_space
}
}
/// The two axes of CSS `background-position`.
///
/// The derived default is the CSS initial value `0% 0%`. A one-value
/// longhand such as `background-position: 0` is parsed as `0 center`.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct BackgroundPosition {
pub x: BackgroundPositionAxis,
pub y: BackgroundPositionAxis,
}
impl BackgroundPosition {
pub const fn new(x: BackgroundPositionAxis, y: BackgroundPositionAxis) -> Self {
Self { x, y }
}
}
/// Fill rule for a CSS `clip-path: polygon(...)`.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ClipPathFillRule {
@@ -255,6 +314,69 @@ pub struct ClipPathPolygon {
pub points: Vec<(Dimension, Dimension)>,
}
/// One CSS gradient from the ordered `background-image` layer list.
///
/// CSS paints the first authored layer closest to the user, so paint walks
/// this vector in reverse over the background color. Keeping the authored
/// order is essential when a hero combines a linear fade with several
/// partially transparent radial highlights.
#[derive(Debug, Clone, PartialEq)]
pub enum BackgroundGradientLayer {
Linear {
angle: f32,
stops: Vec<([u8; 4], Option<f32>)>,
/// Authored stop positions, retained until paint so absolute lengths
/// can resolve against the final gradient-line length.
stop_positions: Vec<Option<String>>,
/// `repeating-linear-gradient()` repeats the interval from its first
/// resolved stop through its last, independently of background tiling.
repeating: bool,
},
Radial {
center: (f32, f32),
stops: Vec<([u8; 4], Option<f32>)>,
},
Conic {
angle: f32,
center: (f32, f32),
stops: Vec<([u8; 4], Option<f32>)>,
},
}
/// One computed CSS counter operation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CounterDirective {
pub name: String,
pub value: i32,
}
/// Counter styles supported in generated text.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum GeneratedCounterStyle {
#[default]
Decimal,
DecimalLeadingZero,
LowerAlpha,
UpperAlpha,
LowerRoman,
UpperRoman,
}
/// One item in a computed `content` value.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GeneratedContentItem {
Text(String),
Counter {
name: String,
style: GeneratedCounterStyle,
},
Counters {
name: String,
separator: String,
style: GeneratedCounterStyle,
},
}
/// The subset of CSS that influences box layout. Expanded in later phases.
#[derive(Debug, Clone, Default)]
pub struct LayoutStyle {
@@ -285,6 +407,12 @@ pub struct LayoutStyle {
/// descendants while leaving auto-width blocks fill-available.
pub legacy_center: bool,
pub width: Dimension,
/// The preferred inline size is the intrinsic `fit-content` keyword.
///
/// Taffy's box-size dimension cannot represent intrinsic sizing keywords,
/// so `width` remains `Auto` while the DOM layout convergence pass applies
/// the CSS shrink-to-fit formula from min/max-content measurements.
pub width_fit_content: bool,
pub height: Dimension,
/// Which box edge `width`/`height` and min/max sizes describe. CSS starts
/// at `content-box`; many modern reset sheets opt into `border-box`.
@@ -373,8 +501,10 @@ pub struct LayoutStyle {
/// the center is a fraction of the border box, and stops are normalized
/// during paint. Conic gradients commonly provide the color source for a
/// repeated SVG mask in modern hero artwork.
pub background_conic_gradient:
Option<(f32, (f32, f32), Vec<([u8; 4], Option<f32>)>)>,
pub background_conic_gradient: Option<(f32, (f32, f32), Vec<([u8; 4], Option<f32>)>)>,
/// Every parsed gradient in authored background-layer order. The legacy
/// single-kind fields above remain populated for mask/text fast paths.
pub background_gradient_layers: Vec<BackgroundGradientLayer>,
/// The first `url(...)` reference from `background`/`background-image`
/// (gradients and repeat keywords in the same shorthand are ignored: we
/// paint the referenced image, not the gradient layer).
@@ -389,10 +519,13 @@ pub struct LayoutStyle {
/// Keyword `background-size` behavior. `None` is CSS `auto`, which uses
/// the image's intrinsic dimensions rather than stretching it to the box.
pub background_size_fit: Option<ObjectFit>,
/// `background-position` as a 0.0-1.0 fraction per axis (0,0 = default
/// top-left; 1,0.5 = "right center"). The fraction applies to the leftover
/// space after resolving explicit, intrinsic, cover, or contain size.
pub background_position: (f32, f32),
/// `background-position`, retained as a length-plus-percentage per axis.
/// Percentages apply to the leftover space after resolving explicit,
/// intrinsic, cover, or contain size.
pub background_position: BackgroundPosition,
/// Explicit `(repeat-x, repeat-y)` choice. `None` is the CSS initial
/// `repeat` in both axes.
pub background_repeat: Option<(bool, bool)>,
/// `background-clip: text` / `-webkit-background-clip: text`: the background
/// paints only through the element's glyphs, not as a filled box. Combined
/// with a transparent text color this is the common gradient-text technique
@@ -571,6 +704,22 @@ pub struct LayoutStyle {
/// clipped box used for skip-links and screen-reader-only labels) actually
/// invisible instead of painting its text wherever it lands.
pub overflow_hidden: bool,
/// Independent computed overflow clips. `overflow_hidden` remains the
/// aggregate compatibility/BFC flag; paint and automatic minimum sizing
/// must consult the relevant axis.
pub overflow_clip_x: bool,
pub overflow_clip_y: bool,
pub(crate) overflow_axes_set: bool,
pub(crate) overflow_specified_x: u8,
pub(crate) overflow_specified_y: u8,
pub(crate) overflow_inherit_x: bool,
pub(crate) overflow_inherit_y: bool,
pub(crate) overflow_scroll_x: bool,
pub(crate) overflow_scroll_y: bool,
/// This element's authored overflow is propagated to the viewport. Its
/// own box therefore behaves as `overflow: visible` for layout/BFC
/// purposes while the capture viewport supplies the paint clip.
pub(crate) overflow_propagated_to_viewport: bool,
/// Whether computed overflow establishes a scroll container. `clip`
/// clips paint but deliberately does not establish one, which matters for
/// selecting the scrollport that controls a sticky descendant.
@@ -611,15 +760,16 @@ pub struct LayoutStyle {
/// `clear`, when set: this element moves below preceding floats on the
/// given side(s), ending their float zone.
pub clear: Option<Clear>,
/// Non-inherited CSS counter operations in computed declaration order.
/// Reset operations run before increments on the same element.
pub counter_reset: Vec<CounterDirective>,
pub counter_increment: Vec<CounterDirective>,
pub counter_set: Vec<CounterDirective>,
/// Resolved during the inheritance pass: true when this element should
/// not be painted at all, either from its own or an inherited
/// `visibility: hidden`, or because the product of its own and every
/// ancestor's `opacity` has collapsed to (near) zero. Real `opacity`
/// composites as translucent groups, which our paint step does not do;
/// collapsing it to a binary paint/don't-paint decision is enough to
/// make the extremely common "opacity:0 + visibility:hidden" collapsed
/// dropdown/panel pattern actually invisible, without needing full alpha
/// compositing.
/// ancestor's `opacity` is zero. Fractional values remain paintable and
/// are isolated into composited groups by the paint pass.
pub effectively_invisible: bool,
/// A CSS image supplied by `content: url(...)` on a replaced element.
@@ -633,6 +783,9 @@ pub struct LayoutStyle {
/// children, same as if it were real text content.
pub before_content: Option<String>,
pub after_content: Option<String>,
/// Typed computed `content` items retained until the document-order
/// counter pass can resolve `counter()` and `counters()`.
pub generated_content: Option<Vec<GeneratedContentItem>>,
/// Computed boxes generated by `::before`/`::after`. Text-only pseudos
/// continue through `before_content`/`after_content`; these styles retain
/// positioned decorative boxes for layout-independent painting.
@@ -696,9 +849,10 @@ pub struct LayoutStyle {
/// (this is what underlines links, which are underlined by UA default).
pub underline: Option<bool>,
/// `font-style: italic|oblique`. Inherited. Selects the oblique face when
/// shaping (we embed the DejaVu Sans oblique/bold-oblique faces). `None`
/// means inherit.
/// `font-style: italic|oblique`. Inherited. Selects an available oblique
/// face when shaping; the bundled Linux `system-ui` face synthesizes its
/// slant from DejaVu Sans regular/bold to match Chromium. `None` means
/// inherit.
pub font_style_italic: Option<bool>,
/// `object-fit` for a replaced element (`<img>`). Controls how the decoded
@@ -715,6 +869,12 @@ pub struct LayoutStyle {
/// off-screen skip-link out of view instead of painting it on-screen. Not
/// inherited (transform is a non-inherited property).
pub transform_translate: Option<(Dimension, Dimension)>,
/// Individual CSS `translate` property. This composes independently with
/// the legacy `transform` property, so `transform:none` must not clear it.
/// Functional values are retained separately until the final border box
/// is known because percentages resolve against that box's own axes.
pub individual_translate: Option<(Dimension, Dimension)>,
pub individual_translate_expressions: [Option<String>; 2],
/// Independent CSS-property triggers that establish containing blocks for
/// absolute and fixed descendants. Kept as a bitset so `filter:none`
/// cannot clear a transform/containment trigger from another property.
@@ -766,11 +926,28 @@ pub(crate) const CB_TRIGGER_PERSPECTIVE: u16 = 1 << 3;
pub(crate) const CB_TRIGGER_CONTAIN: u16 = 1 << 4;
pub(crate) const CB_TRIGGER_WILL_CHANGE: u16 = 1 << 5;
pub(crate) const CB_TRIGGER_CONTENT_VISIBILITY: u16 = 1 << 6;
pub(crate) const CB_TRIGGER_TRANSLATE: u16 = 1 << 7;
impl LayoutStyle {
pub(crate) fn establishes_positioning_containing_block(&self) -> bool {
self.containing_block_triggers != 0
}
pub(crate) fn clips_overflow_x(&self) -> bool {
if self.overflow_axes_set {
self.overflow_clip_x
} else {
self.overflow_hidden
}
}
pub(crate) fn clips_overflow_y(&self) -> bool {
if self.overflow_axes_set {
self.overflow_clip_y
} else {
self.overflow_hidden
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -1070,15 +1247,27 @@ pub(crate) fn to_taffy_style(style: &LayoutStyle) -> Style {
width: dimension(style.width),
height: dimension(style.height),
};
// Tell taffy's own layout algorithm about `overflow: hidden`, not just our
// paint-time clip rects: per spec, a flex/grid item's automatic minimum
// size is content-based only when its overflow is `visible`, and `0`
// otherwise. Without this, an explicit `height: 0; overflow: hidden`
// element (the common collapsed-dropdown-panel pattern: hidden until a
// sibling checkbox is checked) still grows to fit its content, since
// taffy has no way to know it should not.
if style.overflow_hidden {
s.overflow = taffy::Point { x: taffy::style::Overflow::Hidden, y: taffy::style::Overflow::Hidden };
// Tell taffy's layout algorithm about computed overflow, not just our
// paint-time clips: a flex/grid automatic minimum depends on the relevant
// axis. Overflow propagated from html/body belongs to the viewport and
// must leave the source element's own layout overflow visible.
if style.overflow_hidden && !style.overflow_propagated_to_viewport {
s.overflow = taffy::Point {
x: if style.overflow_scroll_x {
taffy::style::Overflow::Hidden
} else if style.clips_overflow_x() {
taffy::style::Overflow::Clip
} else {
taffy::style::Overflow::Visible
},
y: if style.overflow_scroll_y {
taffy::style::Overflow::Hidden
} else if style.clips_overflow_y() {
taffy::style::Overflow::Clip
} else {
taffy::style::Overflow::Visible
},
};
}
s.min_size = taffy::Size {
width: dimension(style.min_width),
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+452
View File
@@ -3017,3 +3017,455 @@ fn text_indent_is_inherited_and_initial_resets_to_zero() {
Some(obscura_render::Dimension::Px(40.0))
);
}
/// Modern utility frameworks emit logical insets for full-width fixed bars
/// (`inset-x-0` in Tailwind is `inset-inline:0`). Both inline edges constrain
/// an auto-width positioned box, so it must stretch across the viewport.
#[test]
fn logical_inline_insets_stretch_a_fixed_auto_width_box() {
let tree = parse_html(
r#"
<style>
html, body { margin:0 }
#bar {
position:fixed;
inset-inline:0;
inset-block-start:0;
height:56px;
}
</style>
<div id="bar"><span>Brand</span><nav>Links</nav></div>
"#,
);
let layout = layout_dom(&tree, (1440.0, 1000.0));
let bar = layout.rects[&tree.get_element_by_id("bar").unwrap()];
assert_eq!(bar.x, 0.0, "{bar:?}");
assert_eq!(bar.y, 0.0, "{bar:?}");
assert_eq!(bar.width, 1440.0, "{bar:?}");
assert_eq!(bar.height, 56.0, "{bar:?}");
}
#[test]
fn flex_automatic_minimum_uses_overflow_on_the_matching_axis() {
let tree = parse_html(
r#"
<style>
html, body { margin:0 }
.row { display:flex; width:40px }
.item { flex-shrink:1 }
.wide { width:100px; height:10px }
#block-clipped { overflow-y:clip }
#inline-clipped { overflow-x:hidden }
</style>
<div class="row"><div id="block-clipped" class="item"><div class="wide"></div></div></div>
<div class="row"><div id="inline-clipped" class="item"><div class="wide"></div></div></div>
"#,
);
let layout = layout_dom(&tree, (200.0, 100.0));
let rect = |id| layout.rects[&tree.get_element_by_id(id).unwrap()];
assert_eq!(
rect("block-clipped").width,
100.0,
"block-axis overflow must not remove the inline automatic minimum"
);
assert_eq!(
rect("inline-clipped").width,
40.0,
"inline-axis non-visible overflow allows the flex item to shrink"
);
}
fn tailwind_line_counter_fixture() -> obscura_dom::tree::DomTree {
parse_html(
r#"
<style>
html, body { margin:0; background:white }
code { display:block; position:relative; width:120px; counter-reset:line }
.line {
display:block;
height:20px;
margin-left:24px;
counter-increment:line;
}
.line::before {
content:counter(line);
display:inline-block;
position:absolute;
left:0;
width:16px;
color:black;
text-align:right;
}
</style>
<code>
<span id="line-one" class="line">alpha</span>
<span id="line-two" class="line">beta</span>
<span id="line-three" class="line">gamma</span>
</code>
"#,
)
}
#[test]
fn css_counters_resolve_generated_line_numbers_in_tree_order() {
let tree = tailwind_line_counter_fixture();
let layout = layout_dom(&tree, (120.0, 80.0));
for (id, expected) in [("line-one", "1"), ("line-two", "2"), ("line-three", "3")] {
let style = &layout.styles[&tree.get_element_by_id(id).unwrap()];
assert_eq!(
style
.before_pseudo
.as_ref()
.and_then(|pseudo| pseudo.before_content.as_deref()),
Some(expected),
"{id}"
);
}
}
#[test]
fn nested_counter_scopes_feed_counters_function_and_expire_at_parent() {
let tree = parse_html(
r#"
<style>
ol { counter-reset:item; list-style:none }
li { counter-increment:item }
li::before { content:counters(item, ".") " " }
</style>
<ol>
<li id="outer-one">one
<ol><li id="inner-one">nested</li></ol>
</li>
<li id="outer-two">two</li>
</ol>
"#,
);
let layout = layout_dom(&tree, (400.0, 200.0));
let generated = |id| {
layout.styles[&tree.get_element_by_id(id).unwrap()]
.before_content
.clone()
};
assert_eq!(generated("outer-one").as_deref(), Some("1 "));
assert_eq!(generated("inner-one").as_deref(), Some("1.1 "));
assert_eq!(generated("outer-two").as_deref(), Some("2 "));
}
#[cfg(feature = "paint")]
#[test]
fn generated_counter_text_reaches_the_positioned_pseudo_paint_path() {
let tree = tailwind_line_counter_fixture();
let pixmap =
obscura_render::paint_dom(&tree, (120.0, 80.0), None).expect("counter fixture paints");
for (line, y_range) in [(1, 0..20), (2, 20..40), (3, 40..60)] {
let dark_gutter_pixels = y_range
.flat_map(|y| (0..20).map(move |x| (x, y)))
.filter(|&(x, y)| {
let pixel = pixmap.pixel(x, y).expect("inside pixmap");
pixel.red() < 100 && pixel.green() < 100 && pixel.blue() < 100
})
.count();
assert!(
dark_gutter_pixels > 2,
"generated line {line} should paint at its own static block-axis position"
);
}
}
#[cfg(feature = "paint")]
#[test]
fn positioned_counter_inside_nested_inline_code_uses_the_code_gutter() {
let tree = parse_html(
r#"
<style>
html, body { margin:0; background:white }
pre {
display:flex;
margin:0;
padding:12px;
width:220px;
background:black;
white-space:pre;
}
code.outer { display:block; width:100% }
code.inner { display:inline }
.with-line-numbers code { counter-reset:line; position:relative }
.line {
display:block;
isolation:isolate;
height:24px;
margin-left:36px;
counter-increment:line;
color:white;
font:13px/24px monospace;
}
.line::before {
content:counter(line);
width:24px;
text-align:right;
color:red;
display:inline-block;
position:absolute;
left:0;
font-family:monospace;
}
</style>
<div class="with-line-numbers">
<pre><code class="outer"><code class="inner"><span class="line"><span style="color:pink">&lt;div</span><span>&gt;</span></span>
<span class="line"><span style="color:pink">&lt;span</span><span>&gt;text&lt;/span&gt;</span></span>
<span class="line"><span style="color:pink">&lt;/div</span><span>&gt;</span></span></code></code></pre>
</div>
"#,
);
let pixmap = obscura_render::paint_dom(&tree, (260.0, 100.0), None)
.expect("nested Tailwind-shaped code fixture paints");
let red_ink = |x_range: std::ops::Range<u32>| {
(0..72)
.flat_map(|y| x_range.clone().map(move |x| (x, y)))
.filter(|&(x, y)| {
let pixel = pixmap.pixel(x, y).expect("inside pixmap");
pixel.red() > 160 && pixel.green() < 100 && pixel.blue() < 100
})
.count()
};
assert!(
red_ink(12..36) > 4,
"line counters must paint in the positioned code gutter"
);
assert_eq!(
red_ink(45..72),
0,
"line counters must not overpaint the margin-shifted source text"
);
}
#[cfg(feature = "paint")]
#[test]
fn positioned_generated_text_honors_text_alignment_inside_its_box() {
let tree = parse_html(
r#"
<style>
html, body { margin:0; background:white }
.row {
position:relative;
width:40px;
height:24px;
}
.row::before {
content:counter(line);
position:absolute;
inset:0 auto auto 0;
width:24px;
height:20px;
color:black;
font:16px/20px monospace;
}
.one { counter-reset:line 1 }
.ten { counter-reset:line 10 }
.right::before { text-align:right }
.center::before { text-align:center }
.left::before { text-align:left }
</style>
<div class="row right one"></div>
<div class="row right ten"></div>
<div class="row center one"></div>
<div class="row center ten"></div>
<div class="row left one"></div>
<div class="row left ten"></div>
"#,
);
let pixmap =
obscura_render::paint_dom(&tree, (80.0, 144.0), None).expect("aligned counters paint");
let ink_bounds = |row: u32| {
let mut min_x = u32::MAX;
let mut max_x = 0;
for y in row * 24..row * 24 + 20 {
for x in 0..24 {
let pixel = pixmap.pixel(x, y).expect("inside pixmap");
if pixel.red() < 100 && pixel.green() < 100 && pixel.blue() < 100 {
min_x = min_x.min(x);
max_x = max_x.max(x);
}
}
}
assert_ne!(min_x, u32::MAX, "row {row} should contain generated text");
(min_x, max_x)
};
let right_one = ink_bounds(0);
let right_ten = ink_bounds(1);
let center_one = ink_bounds(2);
let center_ten = ink_bounds(3);
let left_one = ink_bounds(4);
let left_ten = ink_bounds(5);
assert!(
right_one.1.abs_diff(right_ten.1) <= 1,
"right-aligned 1 and 10 should share an end edge: {right_one:?} {right_ten:?}"
);
assert!(
left_one.0.abs_diff(left_ten.0) <= 1,
"left-aligned 1 and 10 should share a start edge: {left_one:?} {left_ten:?}"
);
assert!(
(center_one.0 + center_one.1).abs_diff(center_ten.0 + center_ten.1) <= 2,
"center-aligned 1 and 10 should share a center: {center_one:?} {center_ten:?}"
);
assert!(
left_one.0 < center_one.0 && center_one.0 < right_one.0,
"single-digit ink should move across the pseudo box: \
left={left_one:?} center={center_one:?} right={right_one:?}"
);
}
/// A percentage block size on an in-flow child resolves against the
/// containing block's definite content box. Floating the containing block
/// changes its outer inline participation, not that block-axis percentage
/// basis.
#[test]
fn percentage_height_inside_floated_and_inline_block_controls_uses_content_box() {
let tree = parse_html(
r#"
<style>
html, body { margin:0 }
#row { width:400px; height:44px }
.control {
box-sizing:border-box;
width:50%;
height:100%;
padding-block:1px;
}
#floated { float:left }
#inline { display:inline-block }
.icon {
display:inline-block;
width:24px;
height:100%;
}
</style>
<div id="row">
<button id="floated" class="control"><span id="float-icon" class="icon"></span></button><button id="inline" class="control"><span id="inline-icon" class="icon"></span></button>
</div>
"#,
);
let layout = layout_dom(&tree, (400.0, 200.0));
let rect = |id| layout.rects[&tree.get_element_by_id(id).unwrap()];
assert_eq!(rect("floated").height, 44.0);
assert_eq!(rect("inline").height, 44.0);
assert_eq!(
rect("float-icon").height,
42.0,
"a floated containing block retains its definite content-box basis"
);
assert_eq!(
rect("inline-icon").height,
42.0,
"border-box padding must not leak into the child percentage basis"
);
}
/// A percentage height computes to auto when its containing block's height is
/// content-dependent. The resulting post-layout height must not be recycled as
/// a new percentage basis on a repair pass.
#[test]
fn percentage_height_chain_under_auto_height_atomic_parent_stays_indefinite() {
let tree = parse_html(
r#"
<style>
html, body { margin:0 }
.control { height:100%; padding-block:2px }
#floated { float:left }
#inline { display:inline-block }
.icon { display:inline-block; height:100% }
.content { height:13px }
</style>
<section><div id="floated" class="control"><div id="float-icon" class="icon"><div class="content"></div></div></div></section>
<section><div id="inline" class="control"><div id="inline-icon" class="icon"><div class="content"></div></div></div></section>
"#,
);
let layout = layout_dom(&tree, (400.0, 200.0));
let id = |name| tree.get_element_by_id(name).unwrap();
for name in ["floated", "inline", "float-icon", "inline-icon"] {
assert_eq!(
layout.styles[&id(name)].height,
obscura_render::Dimension::Auto,
"{name} must retain an indefinite block-axis percentage basis"
);
}
assert_eq!(layout.rects[&id("float-icon")].height, 13.0);
assert_eq!(layout.rects[&id("inline-icon")].height, 13.0);
}
#[test]
fn fit_content_width_shrink_wraps_a_grid_item_instead_of_stretching() {
let tree = parse_html(
r#"
<body style="margin:0">
<div style="display:grid;width:500px">
<a id="cta" style="display:block;width:fit-content;padding:8px 12px">Launch Brave</a>
</div>
</body>
"#,
);
let layout = layout_dom(&tree, (800.0, 200.0));
let id = tree.get_element_by_id("cta").unwrap();
let rect = layout.rects[&id];
assert!(
layout.styles[&id].width_fit_content,
"the intrinsic keyword must survive the computed-style translation"
);
assert!(
rect.width > 80.0 && rect.width < 200.0,
"a short CTA should shrink-wrap within its grid area, not stretch to 500px: {rect:?}"
);
}
#[test]
fn fit_content_width_uses_available_space_and_wraps_breakable_text() {
let tree = parse_html(
r#"
<body style="margin:0">
<div style="width:140px">
<div id="fit" style="width:fit-content;font-size:16px;line-height:20px">several breakable words need wrapping here</div>
</div>
</body>
"#,
);
let layout = layout_dom(&tree, (500.0, 200.0));
let rect = layout.rects[&tree.get_element_by_id("fit").unwrap()];
assert!(
(rect.width - 140.0).abs() < 0.1,
"fit-content should clamp max-content to the containing block: {rect:?}"
);
assert!(
rect.height >= 40.0,
"the clamped fit-content box must reflow its breakable text: {rect:?}"
);
}
#[test]
fn fit_content_width_keeps_an_unbreakable_min_content_floor() {
let tree = parse_html(
r#"
<body style="margin:0">
<div style="width:60px">
<div id="fit" style="width:fit-content;font-size:16px">supercalifragilisticexpialidocious</div>
</div>
</body>
"#,
);
let layout = layout_dom(&tree, (500.0, 200.0));
let rect = layout.rects[&tree.get_element_by_id("fit").unwrap()];
assert!(
rect.width > 60.0,
"min-content must win when an unbreakable token is wider than the available space: {rect:?}"
);
}