Expand modern CSS layout and paint parity
This commit is contained in:
1 parent
87f6d5a47d
commit
af5ae70d31
7 files changed
+4332
-560
No files matched your search
@@ -359,16 +359,17 @@ impl<'a> ContainerQueryEvaluator<'a> {
|
|||||||
if !axis_available {
|
if !axis_available {
|
||||||
return (ContainerQueryTruth::Unknown, Some(id));
|
return (ContainerQueryTruth::Unknown, Some(id));
|
||||||
}
|
}
|
||||||
let truth = query.condition.as_ref().map_or(
|
let truth =
|
||||||
ContainerQueryTruth::True,
|
query
|
||||||
|condition| {
|
.condition
|
||||||
|
.as_ref()
|
||||||
|
.map_or(ContainerQueryTruth::True, |condition| {
|
||||||
evaluate_container_query_expr(
|
evaluate_container_query_expr(
|
||||||
condition,
|
condition,
|
||||||
container,
|
container,
|
||||||
self.snapshot.root_font_size,
|
self.snapshot.root_font_size,
|
||||||
)
|
)
|
||||||
},
|
});
|
||||||
);
|
|
||||||
return (truth, Some(id));
|
return (truth, Some(id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -745,7 +746,7 @@ impl Stylesheet {
|
|||||||
};
|
};
|
||||||
style.color_scheme_dark = host_style.color_scheme_dark;
|
style.color_scheme_dark = host_style.color_scheme_dark;
|
||||||
let inherited_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 {
|
for &(_, _, rule) in &matched {
|
||||||
let expanded = substitute_declarations(&rule.normal_decls, props);
|
let expanded = substitute_declarations(&rule.normal_decls, props);
|
||||||
crate::style::apply_color_scheme_declarations_from(
|
crate::style::apply_color_scheme_declarations_from(
|
||||||
@@ -769,7 +770,7 @@ impl Stylesheet {
|
|||||||
&expanded,
|
&expanded,
|
||||||
);
|
);
|
||||||
if let Some(value) = extract_content(&expanded, tree, nid) {
|
if let Some(value) = extract_content(&expanded, tree, nid) {
|
||||||
content = value;
|
generated_content = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for &(_, _, rule) in &matched {
|
for &(_, _, rule) in &matched {
|
||||||
@@ -779,11 +780,14 @@ impl Stylesheet {
|
|||||||
&expanded,
|
&expanded,
|
||||||
);
|
);
|
||||||
if let Some(value) = extract_content(&expanded, tree, nid) {
|
if let Some(value) = extract_content(&expanded, tree, nid) {
|
||||||
content = value;
|
generated_content = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
style.before_content = content;
|
style.before_content = generated_content
|
||||||
if style.before_content.is_some() || style.content_image.is_some() {
|
.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)
|
Some(style)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -1461,13 +1465,53 @@ fn substitute_var_value(input: &str, props: &HashMap<String, String>, depth: u8)
|
|||||||
substitute_var_value(fallback, props, depth + 1)?
|
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);
|
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..];
|
rest = &after[end + 1..];
|
||||||
}
|
}
|
||||||
out.push_str(rest);
|
out.push_str(rest);
|
||||||
Some(out)
|
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`
|
/// If `selector`'s rightmost compound is the pseudo-element `which`
|
||||||
/// (`"before"` or `"after"`, matching either the modern `::` or legacy `:`
|
/// (`"before"` or `"after"`, matching either the modern `::` or legacy `:`
|
||||||
/// form), return everything before it, trimmed. Only a trailing
|
/// 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
|
/// strings, support the common `attr(name)` form used by component-library
|
||||||
/// buttons and badges. The attribute is resolved against the originating
|
/// buttons and badges. The attribute is resolved against the originating
|
||||||
/// element, as CSS generated content requires.
|
/// 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;
|
let mut result = None;
|
||||||
for raw in crate::style::split_declarations(decls) {
|
for raw in crate::style::split_declarations(decls) {
|
||||||
let Some((name, value)) = raw.split_once(':') else { continue };
|
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);
|
result = Some(None);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let parsed = if let Some(quote) =
|
let parsed = parse_generated_content_items(value, tree, nid);
|
||||||
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 {
|
if let Some(parsed) = parsed {
|
||||||
result = Some(Some(parsed));
|
result = Some(Some(parsed));
|
||||||
} else if value
|
} else if value
|
||||||
@@ -1539,6 +1572,289 @@ fn extract_content(decls: &str, tree: &DomTree, nid: NodeId) -> Option<Option<St
|
|||||||
result
|
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
|
/// Decode CSS string escapes: `\` followed by 1-6 hex digits is a Unicode
|
||||||
/// code point (`\200B` -> U+200B ZERO WIDTH SPACE, ubiquitous in generated
|
/// code point (`\200B` -> U+200B ZERO WIDTH SPACE, ubiquitous in generated
|
||||||
/// `content` for accessible section-edit-link brackets and similar), with a
|
/// `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();
|
let cta = tree.query_selector("#cta").unwrap().unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
extract_content("content:attr(data-label)", &tree, cta),
|
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!(
|
assert_eq!(
|
||||||
extract_content(r#"content:"fallback";content:none"#, &tree, cta),
|
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]
|
#[test]
|
||||||
fn keyframes_retain_every_animation_offset() {
|
fn keyframes_retain_every_animation_offset() {
|
||||||
let css = r#"
|
let css = r#"
|
||||||
@@ -3231,17 +3587,13 @@ mod tests {
|
|||||||
let css = ".card { display:block; @media (max-width: 950px) { width:100%; } }";
|
let css = ".card { display:block; @media (max-width: 950px) { width:100%; } }";
|
||||||
let narrow = parse_stylesheet_for_viewport(css, (900.0, 1000.0));
|
let narrow = parse_stylesheet_for_viewport(css, (900.0, 1000.0));
|
||||||
let wide = parse_stylesheet_for_viewport(css, (1280.0, 720.0));
|
let wide = parse_stylesheet_for_viewport(css, (1280.0, 720.0));
|
||||||
assert!(
|
assert!(narrow
|
||||||
narrow
|
|
||||||
.iter()
|
.iter()
|
||||||
.any(|(selector, declarations)| selector == ".card"
|
.any(|(selector, declarations)| selector == ".card"
|
||||||
&& declarations.contains("width:100%"))
|
&& declarations.contains("width:100%")));
|
||||||
);
|
assert!(!wide
|
||||||
assert!(
|
|
||||||
!wide
|
|
||||||
.iter()
|
.iter()
|
||||||
.any(|(_, declarations)| declarations.contains("width:100%"))
|
.any(|(_, declarations)| declarations.contains("width:100%")));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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]
|
#[test]
|
||||||
fn wildcard_duplicate_and_invalid_property_registrations_are_bounded() {
|
fn wildcard_duplicate_and_invalid_property_registrations_are_bounded() {
|
||||||
let tree = obscura_dom::parse_html(
|
let tree = obscura_dom::parse_html(
|
||||||
|
|||||||
+1065
-172
File diff suppressed because it is too large.
Load diff
@@ -27,11 +27,10 @@ use obscura_dom::tree::{DomTree, NodeId};
|
|||||||
use crate::{Dimension, Display, LayoutStyle, Rect, TextTransform};
|
use crate::{Dimension, Display, LayoutStyle, Rect, TextTransform};
|
||||||
|
|
||||||
// Bundled faces. Chrome on this class of host renders `sans-serif` and the
|
// Bundled faces. Chrome on this class of host renders `sans-serif` and the
|
||||||
// ubiquitous Arial/Helvetica/system-ui stacks as Liberation Sans, `serif` as
|
// ubiquitous Arial/Helvetica stacks as Liberation Sans, `system-ui` as DejaVu
|
||||||
// Liberation Serif, and `monospace` as Liberation Mono. Matching those keeps
|
// Sans, `serif` as Liberation Serif, and `monospace` as Liberation Mono.
|
||||||
// text metrics (advance widths, wrapping, line positions) aligned with Chromium
|
// Matching those keeps text metrics (advance widths, wrapping, line positions)
|
||||||
// instead of drifting ~12% wider on DejaVu's wider glyphs. DejaVu Sans is kept
|
// aligned with Chromium instead of drifting between unrelated host faces.
|
||||||
// only as a last-resort fallback for glyphs the Liberation faces lack.
|
|
||||||
static SANS_R: &[u8] = include_bytes!("../assets/liberation-sans.ttf");
|
static SANS_R: &[u8] = include_bytes!("../assets/liberation-sans.ttf");
|
||||||
static SANS_B: &[u8] = include_bytes!("../assets/liberation-sans-bold.ttf");
|
static SANS_B: &[u8] = include_bytes!("../assets/liberation-sans-bold.ttf");
|
||||||
static SANS_O: &[u8] = include_bytes!("../assets/liberation-sans-oblique.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_B: &[u8] = include_bytes!("../assets/liberation-mono-bold.ttf");
|
||||||
static MONO_O: &[u8] = include_bytes!("../assets/liberation-mono-oblique.ttf");
|
static MONO_O: &[u8] = include_bytes!("../assets/liberation-mono-oblique.ttf");
|
||||||
static MONO_BO: &[u8] = include_bytes!("../assets/liberation-mono-boldoblique.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 FAMILY: &str = "Liberation Sans";
|
||||||
const SERIF_FAMILY: &str = "Liberation Serif";
|
const SERIF_FAMILY: &str = "Liberation Serif";
|
||||||
const MONO_FAMILY: &str = "Liberation Mono";
|
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
|
/// 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
|
/// generic families on this host. Chromium's Linux `system-ui` resolves to
|
||||||
/// serif stack -> Liberation Serif, everything else (sans-serif, Arial,
|
/// DejaVu Sans, while `sans-serif`/Arial/Helvetica resolve to Liberation Sans.
|
||||||
/// Helvetica, system-ui, named sans webfonts, ...) -> Liberation Sans. The first
|
/// The first recognizable family wins, matching CSS fallback order.
|
||||||
/// family whose category is recognizable wins, matching CSS fallback order.
|
|
||||||
fn resolve_font_family(fam: Option<&str>) -> &'static str {
|
fn resolve_font_family(fam: Option<&str>) -> &'static str {
|
||||||
let Some(f) = fam else { return FAMILY };
|
let Some(f) = fam else { return FAMILY };
|
||||||
for tok in f.split(',') {
|
for tok in f.split(',') {
|
||||||
let t = tok.trim().trim_matches(|c| c == '"' || c == '\'').trim();
|
if let Some(family) = bundled_family_for_css_token(tok) {
|
||||||
if t.is_empty() {
|
return family;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
// Unrecognized named webfont: keep scanning for a generic fallback.
|
// Unrecognized named webfont: keep scanning for a generic fallback.
|
||||||
}
|
}
|
||||||
FAMILY
|
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)]
|
#[derive(Clone)]
|
||||||
struct LoadedFamily {
|
struct LoadedFamily {
|
||||||
faces: Vec<LoadedFace>,
|
faces: Vec<LoadedFace>,
|
||||||
@@ -112,6 +145,7 @@ struct ResolvedFont {
|
|||||||
family: Arc<str>,
|
family: Arc<str>,
|
||||||
font_id: Option<cosmic_text::fontdb::ID>,
|
font_id: Option<cosmic_text::fontdb::ID>,
|
||||||
metrics: FaceMetrics,
|
metrics: FaceMetrics,
|
||||||
|
synthetic_italic: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -130,11 +164,32 @@ fn resolve_loaded_font(
|
|||||||
) -> ResolvedFont {
|
) -> ResolvedFont {
|
||||||
if let Some(stack) = fam {
|
if let Some(stack) = fam {
|
||||||
for token in stack.split(',') {
|
for token in stack.split(',') {
|
||||||
let name = token
|
let name = token.trim().trim_matches(|c| c == '"' || c == '\'').trim();
|
||||||
.trim()
|
let family = loaded.get(&name.to_ascii_lowercase()).or_else(|| {
|
||||||
.trim_matches(|c| c == '"' || c == '\'')
|
bundled_family_for_css_token(name)
|
||||||
.trim();
|
.and_then(|family| loaded.get(&family.to_ascii_lowercase()))
|
||||||
if let Some(family) = loaded.get(&name.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
|
let exact_style: Vec<_> = family
|
||||||
.faces
|
.faces
|
||||||
.iter()
|
.iter()
|
||||||
@@ -150,39 +205,28 @@ fn resolve_loaded_font(
|
|||||||
.copied()
|
.copied()
|
||||||
.find(|face| (face.min_weight..=face.max_weight).contains(&requested_weight))
|
.find(|face| (face.min_weight..=face.max_weight).contains(&requested_weight))
|
||||||
{
|
{
|
||||||
// The named-family matcher uses fontdb's default weight for
|
// The named-family matcher uses fontdb's default weight for this
|
||||||
// this resource, while a variable face commonly advertises
|
// resource, while a variable face commonly advertises `100 900` in
|
||||||
// `100 900` in CSS. Preserve the descriptor-selected file
|
// CSS. Preserve the descriptor-selected file and its database weight;
|
||||||
// and its database weight; the authored coordinate enters
|
// the authored coordinate enters the canonical axis tuple below.
|
||||||
// the canonical axis tuple below.
|
return Some(ResolvedFont {
|
||||||
return ResolvedFont {
|
|
||||||
family: Arc::clone(&face.name),
|
family: Arc::clone(&face.name),
|
||||||
font_id: face.font_id,
|
font_id: face.font_id,
|
||||||
metrics: face.metrics,
|
metrics: face.metrics,
|
||||||
};
|
synthetic_italic: requested_italic && !face.italic,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
let available: Vec<_> =
|
let available: Vec<_> = candidates.iter().map(|face| face.min_weight).collect();
|
||||||
candidates.iter().map(|face| face.min_weight).collect();
|
|
||||||
let matched = match_font_weight(requested_weight, &available);
|
let matched = match_font_weight(requested_weight, &available);
|
||||||
if let Some(face) = candidates
|
candidates
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|face| face.min_weight == matched)
|
.find(|face| face.min_weight == matched)
|
||||||
{
|
.map(|face| ResolvedFont {
|
||||||
return ResolvedFont {
|
|
||||||
family: Arc::clone(&face.name),
|
family: Arc::clone(&face.name),
|
||||||
font_id: face.font_id,
|
font_id: face.font_id,
|
||||||
metrics: face.metrics,
|
metrics: face.metrics,
|
||||||
};
|
synthetic_italic: requested_italic && !face.italic,
|
||||||
}
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let fallback = resolve_font_family(fam);
|
|
||||||
ResolvedFont {
|
|
||||||
family: Arc::from(fallback),
|
|
||||||
font_id: None,
|
|
||||||
metrics: bundled_face_metrics(fallback),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// CSS Fonts' asymmetric missing-weight search. In particular, 600 selects
|
/// 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 {
|
let (ascent, descent, line_gap) = match family {
|
||||||
SERIF_FAMILY => (1825.0, 443.0, 87.0),
|
SERIF_FAMILY => (1825.0, 443.0, 87.0),
|
||||||
MONO_FAMILY => (1705.0, 615.0, 0.0),
|
MONO_FAMILY => (1705.0, 615.0, 0.0),
|
||||||
|
SYSTEM_FAMILY => (1901.0, 483.0, 0.0),
|
||||||
_ => (1854.0, 434.0, 67.0),
|
_ => (1854.0, 434.0, 67.0),
|
||||||
};
|
};
|
||||||
FaceMetrics {
|
FaceMetrics {
|
||||||
@@ -580,11 +625,8 @@ impl ReplacedItem {
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.map_or(false, |expression| expression.contains('%'))
|
.map_or(false, |expression| expression.contains('%'))
|
||||||
};
|
};
|
||||||
let intrinsic_ratio = if width.is_finite()
|
let intrinsic_ratio =
|
||||||
&& height.is_finite()
|
if width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0 {
|
||||||
&& width > 0.0
|
|
||||||
&& height > 0.0
|
|
||||||
{
|
|
||||||
width / height
|
width / height
|
||||||
} else {
|
} else {
|
||||||
1.0
|
1.0
|
||||||
@@ -740,10 +782,8 @@ impl TextEngine {
|
|||||||
let mut db = cosmic_text::fontdb::Database::new();
|
let mut db = cosmic_text::fontdb::Database::new();
|
||||||
let mut declarations = Vec::new();
|
let mut declarations = Vec::new();
|
||||||
for bytes in [
|
for bytes in [
|
||||||
SANS_R, SANS_B, SANS_O, SANS_BO,
|
SANS_R, SANS_B, SANS_O, SANS_BO, SERIF_R, SERIF_B, SERIF_O, SERIF_BO, MONO_R, MONO_B,
|
||||||
SERIF_R, SERIF_B, SERIF_O, SERIF_BO,
|
MONO_O, MONO_BO, SYSTEM_R, SYSTEM_B,
|
||||||
MONO_R, MONO_B, MONO_O, MONO_BO,
|
|
||||||
FALLBACK,
|
|
||||||
] {
|
] {
|
||||||
for id in db.load_font_source(cosmic_text::fontdb::Source::Binary(Arc::new(bytes))) {
|
for id in db.load_font_source(cosmic_text::fontdb::Source::Binary(Arc::new(bytes))) {
|
||||||
declarations.push((id, None, None, None));
|
declarations.push((id, None, None, None));
|
||||||
@@ -936,6 +976,7 @@ impl TextEngine {
|
|||||||
font_id: context.font_id,
|
font_id: context.font_id,
|
||||||
variations: context.variations.clone(),
|
variations: context.variations.clone(),
|
||||||
italic: context.italic,
|
italic: context.italic,
|
||||||
|
synthetic_italic: context.synthetic_italic,
|
||||||
underline: context.underline,
|
underline: context.underline,
|
||||||
color: context.color,
|
color: context.color,
|
||||||
family: context.family,
|
family: context.family,
|
||||||
@@ -977,9 +1018,7 @@ impl TextEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if spans
|
if spans.iter().all(|(text, _)| {
|
||||||
.iter()
|
|
||||||
.all(|(text, _)| {
|
|
||||||
text.is_empty()
|
text.is_empty()
|
||||||
|| (matches!(
|
|| (matches!(
|
||||||
white_space,
|
white_space,
|
||||||
@@ -988,8 +1027,7 @@ impl TextEngine {
|
|||||||
| crate::WhiteSpace::PreLine
|
| crate::WhiteSpace::PreLine
|
||||||
) && text.trim().is_empty()
|
) && text.trim().is_empty()
|
||||||
&& !text.contains('\n'))
|
&& !text.contains('\n'))
|
||||||
})
|
}) {
|
||||||
{
|
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1208,6 +1246,7 @@ struct SpanAttrs {
|
|||||||
font_id: Option<cosmic_text::fontdb::ID>,
|
font_id: Option<cosmic_text::fontdb::ID>,
|
||||||
variations: Option<Arc<FontVariations>>,
|
variations: Option<Arc<FontVariations>>,
|
||||||
italic: bool,
|
italic: bool,
|
||||||
|
synthetic_italic: bool,
|
||||||
underline: bool,
|
underline: bool,
|
||||||
color: [u8; 4],
|
color: [u8; 4],
|
||||||
family: Arc<str>,
|
family: Arc<str>,
|
||||||
@@ -1234,7 +1273,14 @@ impl SpanAttrs {
|
|||||||
if let Some(font_id) = self.font_id {
|
if let Some(font_id) = self.font_id {
|
||||||
a = a.font_id(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 {
|
if self.letter_spacing.is_finite() && self.letter_spacing != 0.0 {
|
||||||
a = a.letter_spacing(self.letter_spacing / self.font_size.max(1.0));
|
a = a.letter_spacing(self.letter_spacing / self.font_size.max(1.0));
|
||||||
}
|
}
|
||||||
@@ -1280,6 +1326,7 @@ struct SpanCtx {
|
|||||||
font_metrics: FaceMetrics,
|
font_metrics: FaceMetrics,
|
||||||
variations: Option<Arc<FontVariations>>,
|
variations: Option<Arc<FontVariations>>,
|
||||||
italic: bool,
|
italic: bool,
|
||||||
|
synthetic_italic: bool,
|
||||||
underline: bool,
|
underline: bool,
|
||||||
transform: TextTransform,
|
transform: TextTransform,
|
||||||
white_space: crate::WhiteSpace,
|
white_space: crate::WhiteSpace,
|
||||||
@@ -1345,6 +1392,7 @@ fn base_span_ctx(
|
|||||||
font_metrics: font.metrics,
|
font_metrics: font.metrics,
|
||||||
variations,
|
variations,
|
||||||
italic: base.font_style_italic.unwrap_or(false),
|
italic: base.font_style_italic.unwrap_or(false),
|
||||||
|
synthetic_italic: font.synthetic_italic,
|
||||||
underline: base.underline.unwrap_or(false),
|
underline: base.underline.unwrap_or(false),
|
||||||
transform: base.text_transform.unwrap_or(TextTransform::None),
|
transform: base.text_transform.unwrap_or(TextTransform::None),
|
||||||
white_space: base.white_space.unwrap_or_default(),
|
white_space: base.white_space.unwrap_or_default(),
|
||||||
@@ -1401,6 +1449,7 @@ fn collect_node_spans(
|
|||||||
font_id: ctx.font_id,
|
font_id: ctx.font_id,
|
||||||
variations: ctx.variations.clone(),
|
variations: ctx.variations.clone(),
|
||||||
italic: ctx.italic,
|
italic: ctx.italic,
|
||||||
|
synthetic_italic: ctx.synthetic_italic,
|
||||||
underline: ctx.underline,
|
underline: ctx.underline,
|
||||||
color: ctx.color,
|
color: ctx.color,
|
||||||
family: Arc::clone(&ctx.family),
|
family: Arc::clone(&ctx.family),
|
||||||
@@ -1422,7 +1471,9 @@ fn collect_node_spans(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if elem.local.as_ref() == "br" {
|
if elem.local.as_ref() == "br" {
|
||||||
out.push(("\n".to_string(), SpanAttrs {
|
out.push((
|
||||||
|
"\n".to_string(),
|
||||||
|
SpanAttrs {
|
||||||
font_size: ctx.font_size,
|
font_size: ctx.font_size,
|
||||||
line_height: ctx.line_height,
|
line_height: ctx.line_height,
|
||||||
letter_spacing: ctx.letter_spacing,
|
letter_spacing: ctx.letter_spacing,
|
||||||
@@ -1432,11 +1483,13 @@ fn collect_node_spans(
|
|||||||
font_id: ctx.font_id,
|
font_id: ctx.font_id,
|
||||||
variations: ctx.variations.clone(),
|
variations: ctx.variations.clone(),
|
||||||
italic: ctx.italic,
|
italic: ctx.italic,
|
||||||
|
synthetic_italic: ctx.synthetic_italic,
|
||||||
underline: ctx.underline,
|
underline: ctx.underline,
|
||||||
color: ctx.color,
|
color: ctx.color,
|
||||||
family: Arc::clone(&ctx.family),
|
family: Arc::clone(&ctx.family),
|
||||||
clip_fill: ctx.clip_fill,
|
clip_fill: ctx.clip_fill,
|
||||||
}));
|
},
|
||||||
|
));
|
||||||
c.last_was_space = true;
|
c.last_was_space = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1471,6 +1524,7 @@ fn collect_node_spans(
|
|||||||
family: Arc::clone(&ctx.family),
|
family: Arc::clone(&ctx.family),
|
||||||
font_id: ctx.font_id,
|
font_id: ctx.font_id,
|
||||||
metrics: ctx.font_metrics,
|
metrics: ctx.font_metrics,
|
||||||
|
synthetic_italic: ctx.synthetic_italic,
|
||||||
});
|
});
|
||||||
let variations = style
|
let variations = style
|
||||||
.map(|style| resolved_font_variations(style, &font))
|
.map(|style| resolved_font_variations(style, &font))
|
||||||
@@ -1497,6 +1551,7 @@ fn collect_node_spans(
|
|||||||
font_metrics: font.metrics,
|
font_metrics: font.metrics,
|
||||||
variations,
|
variations,
|
||||||
italic: ctx.italic || style.and_then(|s| s.font_style_italic).unwrap_or(false),
|
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
|
// Underline propagates in: an ancestor's underline covers
|
||||||
// descendant text; an element only sets its own via CSS.
|
// descendant text; an element only sets its own via CSS.
|
||||||
underline: ctx.underline || style.and_then(|s| s.underline).unwrap_or(false),
|
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_r = sr + (dst.red() as u32 * inv / 255);
|
||||||
let out_g = sg + (dst.green() as u32 * inv / 255);
|
let out_g = sg + (dst.green() as u32 * inv / 255);
|
||||||
let out_b = sb + (dst.blue() 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);
|
.unwrap_or(dst);
|
||||||
};
|
};
|
||||||
let explicit_variations = metadata_variation(glyph.metadata)
|
let explicit_variations = metadata_variation(glyph.metadata)
|
||||||
.and_then(|index| item.variation_sets.get(index))
|
.and_then(|index| item.variation_sets.get(index))
|
||||||
.map(Arc::clone);
|
.map(Arc::clone);
|
||||||
let effective_variations = glyph.font_is_variable.then(|| {
|
let effective_variations = glyph
|
||||||
|
.font_is_variable
|
||||||
|
.then(|| {
|
||||||
variable_swash.effective_variations(
|
variable_swash.effective_variations(
|
||||||
font_system,
|
font_system,
|
||||||
physical.cache_key.font_id,
|
physical.cache_key.font_id,
|
||||||
@@ -2081,7 +2143,8 @@ impl TextEngine {
|
|||||||
glyph.font_italic_axis,
|
glyph.font_italic_axis,
|
||||||
explicit_variations,
|
explicit_variations,
|
||||||
)
|
)
|
||||||
}).flatten();
|
})
|
||||||
|
.flatten();
|
||||||
if let Some(variations) = effective_variations {
|
if let Some(variations) = effective_variations {
|
||||||
variable_swash.with_pixels(
|
variable_swash.with_pixels(
|
||||||
font_system,
|
font_system,
|
||||||
@@ -2159,6 +2222,92 @@ mod tests {
|
|||||||
const RED: [u8; 4] = [255, 0, 0, 255];
|
const RED: [u8; 4] = [255, 0, 0, 255];
|
||||||
const BLUE: [u8; 4] = [0, 0, 255, 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]
|
#[test]
|
||||||
fn normal_line_height_grid_fits_each_face_metric() {
|
fn normal_line_height_grid_fits_each_face_metric() {
|
||||||
// Values measured from Chromium 145 using the same bundled Linux
|
// Values measured from Chromium 145 using the same bundled Linux
|
||||||
@@ -2540,6 +2689,7 @@ mod tests {
|
|||||||
font_id: None,
|
font_id: None,
|
||||||
variations: Some(Arc::new(variations)),
|
variations: Some(Arc::new(variations)),
|
||||||
italic: false,
|
italic: false,
|
||||||
|
synthetic_italic: false,
|
||||||
underline: true,
|
underline: true,
|
||||||
color: [1, 2, 3, 255],
|
color: [1, 2, 3, 255],
|
||||||
family: Arc::from(FAMILY),
|
family: Arc::from(FAMILY),
|
||||||
@@ -2569,6 +2719,7 @@ mod tests {
|
|||||||
family: Arc::from(FAMILY),
|
family: Arc::from(FAMILY),
|
||||||
font_id: None,
|
font_id: None,
|
||||||
metrics: bundled_face_metrics(FAMILY),
|
metrics: bundled_face_metrics(FAMILY),
|
||||||
|
synthetic_italic: false,
|
||||||
};
|
};
|
||||||
assert!(resolved_font_variations(&style, &static_font).is_none());
|
assert!(resolved_font_variations(&style, &static_font).is_none());
|
||||||
let styles = HashMap::from([(copy, style)]);
|
let styles = HashMap::from([(copy, style)]);
|
||||||
@@ -2598,6 +2749,7 @@ mod tests {
|
|||||||
family: Arc::from(FAMILY),
|
family: Arc::from(FAMILY),
|
||||||
font_id: None,
|
font_id: None,
|
||||||
metrics: bundled_face_metrics(FAMILY),
|
metrics: bundled_face_metrics(FAMILY),
|
||||||
|
synthetic_italic: false,
|
||||||
};
|
};
|
||||||
assert!(resolved_font_variations(&style, &font).is_none());
|
assert!(resolved_font_variations(&style, &font).is_none());
|
||||||
}
|
}
|
||||||
@@ -2614,6 +2766,7 @@ mod tests {
|
|||||||
font_id: None,
|
font_id: None,
|
||||||
variations: None,
|
variations: None,
|
||||||
italic: false,
|
italic: false,
|
||||||
|
synthetic_italic: false,
|
||||||
underline: false,
|
underline: false,
|
||||||
color: [0, 0, 0, 255],
|
color: [0, 0, 0, 255],
|
||||||
family: Arc::from(FAMILY),
|
family: Arc::from(FAMILY),
|
||||||
|
|||||||
@@ -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(...)`.
|
/// Fill rule for a CSS `clip-path: polygon(...)`.
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
pub enum ClipPathFillRule {
|
pub enum ClipPathFillRule {
|
||||||
@@ -255,6 +314,69 @@ pub struct ClipPathPolygon {
|
|||||||
pub points: Vec<(Dimension, Dimension)>,
|
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.
|
/// The subset of CSS that influences box layout. Expanded in later phases.
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct LayoutStyle {
|
pub struct LayoutStyle {
|
||||||
@@ -285,6 +407,12 @@ pub struct LayoutStyle {
|
|||||||
/// descendants while leaving auto-width blocks fill-available.
|
/// descendants while leaving auto-width blocks fill-available.
|
||||||
pub legacy_center: bool,
|
pub legacy_center: bool,
|
||||||
pub width: Dimension,
|
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,
|
pub height: Dimension,
|
||||||
/// Which box edge `width`/`height` and min/max sizes describe. CSS starts
|
/// Which box edge `width`/`height` and min/max sizes describe. CSS starts
|
||||||
/// at `content-box`; many modern reset sheets opt into `border-box`.
|
/// 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
|
/// the center is a fraction of the border box, and stops are normalized
|
||||||
/// during paint. Conic gradients commonly provide the color source for a
|
/// during paint. Conic gradients commonly provide the color source for a
|
||||||
/// repeated SVG mask in modern hero artwork.
|
/// repeated SVG mask in modern hero artwork.
|
||||||
pub background_conic_gradient:
|
pub background_conic_gradient: Option<(f32, (f32, f32), Vec<([u8; 4], Option<f32>)>)>,
|
||||||
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`
|
/// The first `url(...)` reference from `background`/`background-image`
|
||||||
/// (gradients and repeat keywords in the same shorthand are ignored: we
|
/// (gradients and repeat keywords in the same shorthand are ignored: we
|
||||||
/// paint the referenced image, not the gradient layer).
|
/// 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
|
/// Keyword `background-size` behavior. `None` is CSS `auto`, which uses
|
||||||
/// the image's intrinsic dimensions rather than stretching it to the box.
|
/// the image's intrinsic dimensions rather than stretching it to the box.
|
||||||
pub background_size_fit: Option<ObjectFit>,
|
pub background_size_fit: Option<ObjectFit>,
|
||||||
/// `background-position` as a 0.0-1.0 fraction per axis (0,0 = default
|
/// `background-position`, retained as a length-plus-percentage per axis.
|
||||||
/// top-left; 1,0.5 = "right center"). The fraction applies to the leftover
|
/// Percentages apply to the leftover space after resolving explicit,
|
||||||
/// space after resolving explicit, intrinsic, cover, or contain size.
|
/// intrinsic, cover, or contain size.
|
||||||
pub background_position: (f32, f32),
|
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
|
/// `background-clip: text` / `-webkit-background-clip: text`: the background
|
||||||
/// paints only through the element's glyphs, not as a filled box. Combined
|
/// 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
|
/// 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
|
/// clipped box used for skip-links and screen-reader-only labels) actually
|
||||||
/// invisible instead of painting its text wherever it lands.
|
/// invisible instead of painting its text wherever it lands.
|
||||||
pub overflow_hidden: bool,
|
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`
|
/// Whether computed overflow establishes a scroll container. `clip`
|
||||||
/// clips paint but deliberately does not establish one, which matters for
|
/// clips paint but deliberately does not establish one, which matters for
|
||||||
/// selecting the scrollport that controls a sticky descendant.
|
/// 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
|
/// `clear`, when set: this element moves below preceding floats on the
|
||||||
/// given side(s), ending their float zone.
|
/// given side(s), ending their float zone.
|
||||||
pub clear: Option<Clear>,
|
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
|
/// Resolved during the inheritance pass: true when this element should
|
||||||
/// not be painted at all, either from its own or an inherited
|
/// not be painted at all, either from its own or an inherited
|
||||||
/// `visibility: hidden`, or because the product of its own and every
|
/// `visibility: hidden`, or because the product of its own and every
|
||||||
/// ancestor's `opacity` has collapsed to (near) zero. Real `opacity`
|
/// ancestor's `opacity` is zero. Fractional values remain paintable and
|
||||||
/// composites as translucent groups, which our paint step does not do;
|
/// are isolated into composited groups by the paint pass.
|
||||||
/// 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.
|
|
||||||
pub effectively_invisible: bool,
|
pub effectively_invisible: bool,
|
||||||
|
|
||||||
/// A CSS image supplied by `content: url(...)` on a replaced element.
|
/// 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.
|
/// children, same as if it were real text content.
|
||||||
pub before_content: Option<String>,
|
pub before_content: Option<String>,
|
||||||
pub after_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
|
/// Computed boxes generated by `::before`/`::after`. Text-only pseudos
|
||||||
/// continue through `before_content`/`after_content`; these styles retain
|
/// continue through `before_content`/`after_content`; these styles retain
|
||||||
/// positioned decorative boxes for layout-independent painting.
|
/// 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).
|
/// (this is what underlines links, which are underlined by UA default).
|
||||||
pub underline: Option<bool>,
|
pub underline: Option<bool>,
|
||||||
|
|
||||||
/// `font-style: italic|oblique`. Inherited. Selects the oblique face when
|
/// `font-style: italic|oblique`. Inherited. Selects an available oblique
|
||||||
/// shaping (we embed the DejaVu Sans oblique/bold-oblique faces). `None`
|
/// face when shaping; the bundled Linux `system-ui` face synthesizes its
|
||||||
/// means inherit.
|
/// slant from DejaVu Sans regular/bold to match Chromium. `None` means
|
||||||
|
/// inherit.
|
||||||
pub font_style_italic: Option<bool>,
|
pub font_style_italic: Option<bool>,
|
||||||
|
|
||||||
/// `object-fit` for a replaced element (`<img>`). Controls how the decoded
|
/// `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
|
/// off-screen skip-link out of view instead of painting it on-screen. Not
|
||||||
/// inherited (transform is a non-inherited property).
|
/// inherited (transform is a non-inherited property).
|
||||||
pub transform_translate: Option<(Dimension, Dimension)>,
|
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
|
/// Independent CSS-property triggers that establish containing blocks for
|
||||||
/// absolute and fixed descendants. Kept as a bitset so `filter:none`
|
/// absolute and fixed descendants. Kept as a bitset so `filter:none`
|
||||||
/// cannot clear a transform/containment trigger from another property.
|
/// 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_CONTAIN: u16 = 1 << 4;
|
||||||
pub(crate) const CB_TRIGGER_WILL_CHANGE: u16 = 1 << 5;
|
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_CONTENT_VISIBILITY: u16 = 1 << 6;
|
||||||
|
pub(crate) const CB_TRIGGER_TRANSLATE: u16 = 1 << 7;
|
||||||
|
|
||||||
impl LayoutStyle {
|
impl LayoutStyle {
|
||||||
pub(crate) fn establishes_positioning_containing_block(&self) -> bool {
|
pub(crate) fn establishes_positioning_containing_block(&self) -> bool {
|
||||||
self.containing_block_triggers != 0
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -1070,15 +1247,27 @@ pub(crate) fn to_taffy_style(style: &LayoutStyle) -> Style {
|
|||||||
width: dimension(style.width),
|
width: dimension(style.width),
|
||||||
height: dimension(style.height),
|
height: dimension(style.height),
|
||||||
};
|
};
|
||||||
// Tell taffy's own layout algorithm about `overflow: hidden`, not just our
|
// Tell taffy's layout algorithm about computed overflow, not just our
|
||||||
// paint-time clip rects: per spec, a flex/grid item's automatic minimum
|
// paint-time clips: a flex/grid automatic minimum depends on the relevant
|
||||||
// size is content-based only when its overflow is `visible`, and `0`
|
// axis. Overflow propagated from html/body belongs to the viewport and
|
||||||
// otherwise. Without this, an explicit `height: 0; overflow: hidden`
|
// must leave the source element's own layout overflow visible.
|
||||||
// element (the common collapsed-dropdown-panel pattern: hidden until a
|
if style.overflow_hidden && !style.overflow_propagated_to_viewport {
|
||||||
// sibling checkbox is checked) still grows to fit its content, since
|
s.overflow = taffy::Point {
|
||||||
// taffy has no way to know it should not.
|
x: if style.overflow_scroll_x {
|
||||||
if style.overflow_hidden {
|
taffy::style::Overflow::Hidden
|
||||||
s.overflow = taffy::Point { x: taffy::style::Overflow::Hidden, y: 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 {
|
s.min_size = taffy::Size {
|
||||||
width: dimension(style.min_width),
|
width: dimension(style.min_width),
|
||||||
|
|||||||
+1083
-152
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -3017,3 +3017,455 @@ fn text_indent_is_inherited_and_initial_resets_to_zero() {
|
|||||||
Some(obscura_render::Dimension::Px(40.0))
|
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"><div</span><span>></span></span>
|
||||||
|
<span class="line"><span style="color:pink"><span</span><span>>text</span></span></span>
|
||||||
|
<span class="line"><span style="color:pink"></div</span><span>></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:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user