Improve modern layout and viewport rendering

This commit is contained in:
SGavrl committed 2026-07-30 20:24:57 +00:00
1 parent aff0172987
commit 10b8492116
22 files changed
+5978 -450

No files matched your search

+402 -41
View File
@@ -129,6 +129,7 @@ impl Stylesheet {
matcher: &mut Matcher,
nid: NodeId,
props: &HashMap<String, String>,
host_style: &LayoutStyle,
) -> (Option<LayoutStyle>, Option<LayoutStyle>) {
let build = |rules: &[PseudoRule], matcher: &mut Matcher| {
let mut matched: Vec<(u32, usize, &PseudoRule)> = rules
@@ -140,24 +141,55 @@ impl Stylesheet {
return None;
}
matched.sort_unstable_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
let mut style = LayoutStyle::default();
// Generated ::before/::after boxes have an inline outer display
// by default. LayoutStyle's general default is block because it
// primarily represents ordinary DOM boxes, so set the pseudo
// initial value explicitly before applying author declarations.
let mut style = LayoutStyle {
display: crate::Display::Inline,
..Default::default()
};
style.color_scheme_dark = host_style.color_scheme_dark;
let inherited_color_scheme_dark = host_style.color_scheme_dark;
let mut content = None;
for &(_, _, rule) in &matched {
let expanded = substitute_vars(&rule.normal_decls, props, 0);
crate::style::apply_declarations(&mut style, &expanded);
let expanded = substitute_declarations(&rule.normal_decls, props);
crate::style::apply_color_scheme_declarations_from(
&mut style,
&expanded,
inherited_color_scheme_dark,
);
}
for &(_, _, rule) in &matched {
let expanded = substitute_declarations(&rule.important_decls, props);
crate::style::apply_color_scheme_declarations_from(
&mut style,
&expanded,
inherited_color_scheme_dark,
);
}
for &(_, _, rule) in &matched {
let expanded = substitute_declarations(&rule.normal_decls, props);
crate::style::apply_declarations_with_locked_color_scheme(
&mut style,
&expanded,
);
if let Some(value) = extract_content(&expanded, tree, nid) {
content = value;
}
}
for &(_, _, rule) in &matched {
let expanded = substitute_vars(&rule.important_decls, props, 0);
crate::style::apply_declarations(&mut style, &expanded);
let expanded = substitute_declarations(&rule.important_decls, props);
crate::style::apply_declarations_with_locked_color_scheme(
&mut style,
&expanded,
);
if let Some(value) = extract_content(&expanded, tree, nid) {
content = value;
}
}
style.before_content = content;
if style.before_content.is_some() {
if style.before_content.is_some() || style.content_image.is_some() {
Some(style)
} else {
None
@@ -254,20 +286,80 @@ impl Stylesheet {
} else {
let mut m = parent_props.clone();
for (k, v) in own {
m.insert(k, v);
match v.trim().to_ascii_lowercase().as_str() {
// CSS-wide keywords on a custom property are cascade
// instructions, not literal token streams. `initial`
// produces the guaranteed-invalid value so var() must use
// its fallback even when an ancestor defined the token.
"initial" => {
m.remove(&k);
}
// Custom properties inherit by default. `unset` therefore
// has the same effect as `inherit`; approximate both
// revert forms with the inherited author value as well.
"inherit" | "unset" | "revert" | "revert-layer" => {
if let Some(inherited) = parent_props.get(&k) {
m.insert(k, inherited.clone());
} else {
m.remove(&k);
}
}
_ => {
m.insert(k, v);
}
}
}
Some(m)
};
let props = effective.as_ref().unwrap_or(parent_props);
let inherited_color_scheme_dark = style.color_scheme_dark;
// `light-dark()` resolves against the element's final used color
// scheme, not the declaration order. Determine the scheme winner
// across the complete author cascade before applying any color-valued
// property. The style starts with its inherited scheme.
for &(_, _, i) in &matched {
let expanded =
substitute_declarations(&self.rules[i].normal_decls, props);
crate::style::apply_color_scheme_declarations_from(
style,
&expanded,
inherited_color_scheme_dark,
);
}
let expanded = substitute_declarations(&inline_normal, props);
crate::style::apply_color_scheme_declarations_from(
style,
&expanded,
inherited_color_scheme_dark,
);
for &(_, _, i) in &matched {
let expanded =
substitute_declarations(&self.rules[i].important_decls, props);
crate::style::apply_color_scheme_declarations_from(
style,
&expanded,
inherited_color_scheme_dark,
);
}
let expanded = substitute_declarations(&inline_important, props);
crate::style::apply_color_scheme_declarations_from(
style,
&expanded,
inherited_color_scheme_dark,
);
// Pass 2: apply normal declarations with `var()` substituted against
// the resolved custom-property map.
for &(_, _, i) in &matched {
let expanded = substitute_vars(&self.rules[i].normal_decls, props, 0);
crate::style::apply_declarations(style, &expanded);
let expanded = substitute_declarations(&self.rules[i].normal_decls, props);
crate::style::apply_declarations_with_locked_color_scheme(
style,
&expanded,
);
}
let expanded = substitute_vars(&inline_normal, props, 0);
crate::style::apply_declarations(style, &expanded);
let expanded = substitute_declarations(&inline_normal, props);
crate::style::apply_declarations_with_locked_color_scheme(style, &expanded);
// CSS animations form a cascade origin above normal author rules and
// below author !important. A static renderer has no compositor clock;
@@ -277,18 +369,24 @@ impl Stylesheet {
if style.animation_fill_forwards && !style.animation_iteration_infinite {
if let Some(name) = style.animation_name.as_deref() {
if let Some(decls) = self.keyframe_ends.get(name) {
let expanded = substitute_vars(decls, props, 0);
crate::style::apply_declarations(style, &expanded);
let expanded = substitute_declarations(decls, props);
crate::style::apply_declarations_with_locked_color_scheme(
style,
&expanded,
);
}
}
}
for &(_, _, i) in &matched {
let expanded = substitute_vars(&self.rules[i].important_decls, props, 0);
crate::style::apply_declarations(style, &expanded);
let expanded = substitute_declarations(&self.rules[i].important_decls, props);
crate::style::apply_declarations_with_locked_color_scheme(
style,
&expanded,
);
}
let expanded = substitute_vars(&inline_important, props, 0);
crate::style::apply_declarations(style, &expanded);
let expanded = substitute_declarations(&inline_important, props);
crate::style::apply_declarations_with_locked_color_scheme(style, &expanded);
effective
}
}
@@ -372,14 +470,36 @@ fn extract_keyframe_end_states(css: &str) -> Vec<(String, String)> {
found
}
/// Substitute every `var(--name, fallback?)` in `input` with its resolved
/// value from `props` (recursively, so a token that itself references another
/// token resolves; a depth cap guards against reference cycles). A missing
/// variable with no fallback expands to empty, which makes the declaration
/// invalid and dropped, exactly as CSS specifies.
pub(crate) fn substitute_vars(input: &str, props: &HashMap<String, String>, depth: u8) -> String {
if depth > 16 || !input.contains("var(") {
return input.to_string();
/// Resolve variables one declaration at a time. An invalid variable poisons
/// its entire declaration at computed-value time, but must not erase unrelated
/// declarations in the same rule.
fn substitute_declarations(css: &str, props: &HashMap<String, String>) -> String {
let mut expanded = String::new();
for declaration in crate::style::split_declarations(css) {
let Some((name, value)) = declaration.split_once(':') else {
continue;
};
let Some(value) = substitute_var_value(value.trim(), props, 0) else {
continue;
};
expanded.push_str(name.trim());
expanded.push(':');
expanded.push_str(&value);
expanded.push(';');
}
expanded
}
/// Substitute every `var(--name, fallback?)` in one property value. `None`
/// represents CSS's guaranteed-invalid value. Crucially, invalidity propagates
/// through an intermediate custom property so an outer var() can use its own
/// fallback (`--toggle:var(--missing) dark; color:var(--toggle,light)`).
fn substitute_var_value(input: &str, props: &HashMap<String, String>, depth: u8) -> Option<String> {
if depth > 16 {
return None;
}
if !input.contains("var(") {
return Some(input.to_string());
}
let mut out = String::new();
let mut rest = input;
@@ -403,23 +523,28 @@ pub(crate) fn substitute_vars(input: &str, props: &HashMap<String, String>, dept
}
}
let Some(end) = end else {
out.push_str(&rest[pos..]);
return out;
return None;
};
let inner = &after[..end];
let (name, fallback) = match inner.split_once(',') {
Some((n, f)) => (n.trim(), Some(f.trim())),
None => (inner.trim(), None),
};
let replacement = match props.get(name) {
Some(v) => substitute_vars(v, props, depth + 1),
None => fallback.map(|f| substitute_vars(f, props, depth + 1)).unwrap_or_default(),
let resolved = props
.get(name)
.and_then(|value| substitute_var_value(value, props, depth + 1));
let replacement = match resolved {
Some(value) => value,
None => {
let fallback = fallback?;
substitute_var_value(fallback, props, depth + 1)?
}
};
out.push_str(&replacement);
rest = &after[end + 1..];
}
out.push_str(rest);
out
Some(out)
}
/// If `selector`'s rightmost compound is the pseudo-element `which`
@@ -477,6 +602,17 @@ fn extract_content(decls: &str, tree: &DomTree, nid: NodeId) -> Option<Option<St
};
if let Some(parsed) = parsed {
result = Some(Some(parsed));
} else if value
.trim_start()
.get(..4)
.map_or(false, |prefix| prefix.eq_ignore_ascii_case("url("))
{
// An image-valued content declaration supersedes any earlier
// string declaration. The image itself is retained on
// LayoutStyle::content_image by apply_declarations; clearing the
// text here keeps the two views of the winning declaration in
// sync and, importantly, keeps the pseudo alive.
result = Some(None);
}
}
result
@@ -615,7 +751,9 @@ fn flush_at_rule(
rules.extend(parse_stylesheet_for_viewport(inner, viewport));
}
} else if at.starts_with("supports") {
rules.extend(parse_stylesheet_for_viewport(inner, viewport));
if supports_condition_applies(sel) {
rules.extend(parse_stylesheet_for_viewport(inner, viewport));
}
} else if at.starts_with("property") {
// `@property --x { syntax:..; initial-value: V }` declares a custom
// property and its default. Register the initial-value as a `:root`
@@ -648,6 +786,152 @@ fn flush_at_rule(
// layout-relevant rules for us, so drop them.
}
/// Evaluate the boolean subset of CSS Conditional Rules used by modern
/// framework stylesheets. This follows the same shape as Gecko's
/// SupportsCondition tree: declaration/selector leaves, arbitrary grouping,
/// unary `not`, and top-level `and`/`or` chains. Unknown or malformed future
/// syntax is false instead of optimistically enabling a fallback branch.
pub(crate) fn supports_condition_applies(condition: &str) -> bool {
let condition = condition.trim();
let condition = condition
.strip_prefix("@supports")
.or_else(|| condition.strip_prefix("supports"))
.unwrap_or(condition)
.trim();
eval_supports_condition(condition)
}
fn eval_supports_condition(condition: &str) -> bool {
let condition = condition.trim();
if condition.is_empty() {
return false;
}
if let Some(inner) = enclosing_parenthesized(condition) {
return eval_supports_condition(inner);
}
if condition
.get(..3)
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("not"))
&& condition
.as_bytes()
.get(3)
.is_some_and(u8::is_ascii_whitespace)
{
return !eval_supports_condition(condition[3..].trim());
}
if let Some(parts) = split_supports_operator(condition, "or") {
return parts.into_iter().any(eval_supports_condition);
}
if let Some(parts) = split_supports_operator(condition, "and") {
return parts.into_iter().all(eval_supports_condition);
}
let lower = condition.to_ascii_lowercase();
if lower.starts_with("selector(") && condition.ends_with(')') {
let inner = &condition["selector(".len()..condition.len() - 1];
return obscura_dom::selector::parse_selector(inner.trim()).is_ok();
}
let Some((name, value)) = condition.split_once(':') else {
return false;
};
crate::style::supports_declaration(name, value)
}
/// Return the contents when one outer parenthesis pair encloses the complete
/// expression. A declaration leaf such as `(display:grid)` intentionally
/// becomes `display:grid` and is handled after boolean operators.
fn enclosing_parenthesized(condition: &str) -> Option<&str> {
if !condition.starts_with('(') {
return None;
}
let mut depth = 0i32;
let mut quote = None;
for (index, character) in condition.char_indices() {
if let Some(active) = quote {
if character == active {
quote = None;
}
continue;
}
match character {
'\'' | '"' => quote = Some(character),
'(' => depth += 1,
')' => {
depth -= 1;
if depth < 0 {
return None;
}
if depth == 0 {
return (index + character.len_utf8() == condition.len())
.then_some(&condition[1..index]);
}
}
_ => {}
}
}
None
}
fn split_supports_operator<'a>(condition: &'a str, operator: &str) -> Option<Vec<&'a str>> {
let bytes = condition.as_bytes();
let operator = operator.as_bytes();
let mut parts = Vec::new();
let mut start = 0usize;
let mut index = 0usize;
let mut depth = 0i32;
let mut quote = None;
while index < bytes.len() {
let byte = bytes[index];
if let Some(active) = quote {
if byte == active {
quote = None;
} else if byte == b'\\' {
index += 1;
}
index += 1;
continue;
}
match byte {
b'\'' | b'"' => quote = Some(byte),
b'(' => depth += 1,
b')' => {
depth -= 1;
if depth < 0 {
return None;
}
}
_ if depth == 0
&& index + operator.len() <= bytes.len()
&& bytes[index..index + operator.len()].eq_ignore_ascii_case(operator)
&& index > 0
&& bytes[index - 1].is_ascii_whitespace()
&& bytes
.get(index + operator.len())
.is_some_and(u8::is_ascii_whitespace) =>
{
let part = condition[start..index].trim();
if part.is_empty() {
return None;
}
parts.push(part);
index += operator.len();
start = index;
continue;
}
_ => {}
}
index += 1;
}
if depth != 0 || quote.is_some() || parts.is_empty() {
return None;
}
let tail = condition[start..].trim();
if tail.is_empty() {
return None;
}
parts.push(tail);
Some(parts)
}
/// Coarse `@media` evaluation against an assumed 1280px-wide desktop viewport.
///
/// Real stylesheets format media features inconsistently
@@ -678,16 +962,26 @@ fn single_media_query_applies_for_viewport(
) -> bool {
let viewport_w = viewport.0;
let viewport_h = viewport.1;
if query.contains("print") {
let query = query.trim().strip_prefix("@media").unwrap_or(query).trim();
let compact: String = query
.chars()
.filter(|c| !c.is_whitespace())
.flat_map(|c| c.to_lowercase())
.collect();
// Tailwind expresses every `max-*` breakpoint as the negation of a
// min-width query (`not all and (min-width:40rem)`). Evaluate the inner
// condition and invert it; treating all `not all` queries as permanently
// false makes mutually exclusive responsive utilities apply together at
// desktop widths. Unknown browser-targeting features still conservatively
// evaluate true inside and therefore false after negation.
if let Some(inner) = compact.strip_prefix("notalland") {
return !single_media_query_applies_for_viewport(inner, viewport);
}
if compact == "notall" {
return false;
}
let compact: String = query.chars().filter(|c| !c.is_whitespace()).flat_map(|c| c.to_lowercase()).collect();
// A `not all` / `not all and (...)` query is a browser-targeting hack (most
// commonly the Safari-only flex-gap fallback `@media not all and
// (min-resolution:.001dpcm)`, which flips containers from flex to grid); it
// does not apply in a Chromium-like context.
if compact.starts_with("notall") {
if compact.contains("print") {
return false;
}
@@ -933,7 +1227,11 @@ fn denest(
if media_query_applies_for_viewport(pre, viewport) {
denest(sel, &inner, rules, viewport);
}
} else if at.starts_with("supports") || at.starts_with("layer") {
} else if at.starts_with("supports") {
if supports_condition_applies(pre) {
denest(sel, &inner, rules, viewport);
}
} else if at.starts_with("layer") {
denest(sel, &inner, rules, viewport);
}
} else if !pre.is_empty() {
@@ -1253,6 +1551,40 @@ mod tests {
);
}
#[test]
fn supports_conditions_gate_legacy_framework_fallbacks() {
let legacy_probe = "(((-webkit-hyphens:none)) and \
(not (margin-trim:inline))) or \
((-moz-orient:inline) and \
(not (color:rgb(from red r g b))))";
assert!(!supports_condition_applies(legacy_probe));
assert!(supports_condition_applies(
"(display:grid) and (selector(.card > *))"
));
assert!(supports_condition_applies(
"not (unknown-engine-prop:value)"
));
let css = format!(
"@supports {legacy_probe} {{ .legacy {{ line-height:1.5 }} }}\
@supports (display:grid) {{ .modern {{ display:grid }} }}\
.host {{ @supports {legacy_probe} {{ width:999px }} }}"
);
let rules = parse_stylesheet_for_viewport(&css, (1280.0, 720.0));
assert!(
!rules.iter().any(|(selector, declarations)| {
selector == ".legacy" || declarations.contains("999px")
}),
"false supports branches must be skipped: {rules:?}"
);
assert!(
rules.iter().any(|(selector, declarations)| {
selector == ".modern" && declarations.contains("display:grid")
}),
"true supports branch should remain: {rules:?}"
);
}
#[test]
fn media_breakpoints_support_font_relative_lengths_and_ranges() {
assert!(!media_query_applies_for_viewport(
@@ -1273,6 +1605,35 @@ mod tests {
));
}
#[test]
fn negated_min_width_queries_form_max_breakpoints() {
assert!(media_query_applies_for_viewport(
"@media not all and (min-width: 40rem)",
(639.0, 900.0)
));
assert!(!media_query_applies_for_viewport(
"@media not all and (min-width: 40rem)",
(1280.0, 900.0)
));
let css = r#"
.hidden { display: none }
@media not all and (min-width: 40rem) {
.max-sm\:inline { display: inline }
}
@media (min-width: 80rem) {
.xl\:inline { display: inline }
}
"#;
let desktop = parse_stylesheet_for_viewport(css, (1280.0, 900.0));
assert!(!desktop
.iter()
.any(|(selector, _)| selector == r".max-sm\:inline"));
assert!(desktop
.iter()
.any(|(selector, _)| selector == r".xl\:inline"));
}
#[test]
fn media_query_lists_are_or_conditions() {
assert!(media_query_applies_for_viewport(
File diff suppressed because it is too large. Load diff
+418 -85
View File
@@ -14,7 +14,12 @@
use std::{collections::HashMap, sync::Arc};
use cosmic_text::{Align, Attrs, Buffer, Color, Family, FontSystem, Metrics, Shaping, Style, SwashCache, Weight, Wrap};
use cosmic_text::{
Align, Attrs, Buffer, CacheKey, CacheKeyFlags, Color, Family, FontSystem, Metrics, Shaping,
Style, SwashCache, SwashImage, Weight, Wrap,
};
use swash::scale::{image::Content as SwashContent, Render, ScaleContext, Source, StrikeWith};
use swash::zeno::{Angle, Format, Transform, Vector};
use obscura_dom::tree::{DomTree, NodeId};
@@ -90,7 +95,15 @@ struct LoadedFace {
max_weight: u16,
shape_weight: u16,
italic: bool,
variable: bool,
}
#[derive(Clone)]
struct ResolvedFont {
family: Arc<str>,
shape_weight: u16,
/// Authored `wght` coordinate for a ranged @font-face. Cosmic Text 0.12
/// cannot carry this into shaping, so it is kept separately for raster.
variation_weight: Option<u16>,
}
#[derive(Clone)]
@@ -106,7 +119,7 @@ fn resolve_loaded_font(
requested_weight: u16,
requested_italic: bool,
loaded: &HashMap<String, LoadedFamily>,
) -> (Arc<str>, u16) {
) -> ResolvedFont {
if let Some(stack) = fam {
for token in stack.split(',') {
let name = token
@@ -124,15 +137,25 @@ fn resolve_loaded_font(
} else {
exact_style
};
if let Some(face) = candidates.iter().copied().find(|face| {
(face.min_weight..=face.max_weight).contains(&requested_weight)
}) {
let weight = if face.variable {
requested_weight
} else {
face.shape_weight
if let Some(face) = candidates
.iter()
.copied()
.find(|face| (face.min_weight..=face.max_weight).contains(&requested_weight))
{
// cosmic-text 0.12 only accepts the named family when the
// shaping weight exactly matches fontdb's weight for that
// file. A variable face commonly advertises `100 900` in
// CSS while fontdb records its default instance as 400.
// Passing the authored weight makes cosmic-text reject the
// right family and fall through to an unrelated exact-
// weight face. Select the file at its database weight;
// variable-axis shaping is not exposed by this version.
return ResolvedFont {
family: Arc::clone(&face.name),
shape_weight: face.shape_weight,
variation_weight: (face.min_weight != face.max_weight)
.then_some(requested_weight),
};
return (Arc::clone(&face.name), weight);
}
let available: Vec<_> =
candidates.iter().map(|face| face.min_weight).collect();
@@ -141,14 +164,22 @@ fn resolve_loaded_font(
.into_iter()
.find(|face| face.min_weight == matched)
{
return (Arc::clone(&face.name), face.shape_weight);
return ResolvedFont {
family: Arc::clone(&face.name),
shape_weight: face.shape_weight,
variation_weight: None,
};
}
}
}
}
let fallback = resolve_font_family(fam);
let weights: &[u16] = &[400, 700];
(Arc::from(fallback), match_font_weight(requested_weight, weights))
ResolvedFont {
family: Arc::from(fallback),
shape_weight: match_font_weight(requested_weight, weights),
variation_weight: None,
}
}
/// CSS Fonts' asymmetric missing-weight search. In particular, 600 selects
@@ -191,28 +222,6 @@ fn match_font_weight(requested: u16, available: &[u16]) -> u16 {
}
}
fn face_is_variable(data: &[u8], face_index: u32) -> bool {
let base = if data.get(..4) == Some(b"ttcf") {
let offset = 12usize.saturating_add(face_index as usize * 4);
data.get(offset..offset + 4)
.and_then(|bytes| bytes.try_into().ok())
.map(u32::from_be_bytes)
.unwrap_or(0) as usize
} else {
0
};
let Some(count) = data
.get(base + 4..base + 6)
.and_then(|bytes| bytes.try_into().ok())
.map(u16::from_be_bytes)
else {
return false;
};
(0..count as usize).any(|index| {
let start = base + 12 + index * 16;
data.get(start..start + 4) == Some(b"fvar")
})
}
/// Resolve `line-height: normal` from the selected face's horizontal header.
///
/// Chromium's FreeType-backed Linux path grid-fits the ascent, descent, and
@@ -258,6 +267,20 @@ pub(crate) fn used_line_height(style: &LayoutStyle) -> f32 {
/// into an [`InlineItem`]'s clip-text fills.
const META_UNDERLINE: usize = 1;
const META_FILL_SHIFT: usize = 1;
const META_VARIATION_BITS: usize = 10;
const META_VARIATION_SHIFT: usize = usize::BITS as usize - META_VARIATION_BITS;
const META_VARIATION_MASK: usize =
((1usize << META_VARIATION_BITS) - 1) << META_VARIATION_SHIFT;
const META_FILL_MASK: usize = ((1usize << META_VARIATION_SHIFT) - 1) & !META_UNDERLINE;
fn metadata_fill(metadata: usize) -> Option<usize> {
((metadata & META_FILL_MASK) >> META_FILL_SHIFT).checked_sub(1)
}
fn metadata_variation_weight(metadata: usize) -> Option<u16> {
let weight = ((metadata & META_VARIATION_MASK) >> META_VARIATION_SHIFT) as u16;
(weight != 0).then_some(weight)
}
type ClipTextFill = (f32, Vec<([u8; 4], Option<f32>)>);
@@ -286,10 +309,106 @@ pub struct TextEngine {
font_system: FontSystem,
loaded_families: HashMap<String, LoadedFamily>,
swash: SwashCache,
variable_swash: VariableSwashCache,
items: Vec<InlineItem>,
replaced: Vec<ReplacedItem>,
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
struct VariableCacheKey {
glyph: CacheKey,
weight: u16,
}
/// Swash's ordinary cache key intentionally has no variation coordinates.
/// Keep variable instances in a separate cache so a 400 glyph can never be
/// reused for 700 and repeated paint remains O(1) after the first raster.
struct VariableSwashCache {
context: ScaleContext,
images: HashMap<VariableCacheKey, Option<SwashImage>>,
}
impl VariableSwashCache {
fn new() -> Self {
Self {
context: ScaleContext::new(),
images: HashMap::new(),
}
}
fn with_pixels<F: FnMut(i32, i32, Color)>(
&mut self,
font_system: &mut FontSystem,
cache_key: CacheKey,
weight: u16,
base: Color,
mut f: F,
) {
let key = VariableCacheKey {
glyph: cache_key,
weight,
};
let image = self.images.entry(key).or_insert_with(|| {
let font = font_system.get_font(cache_key.font_id)?;
let mut scaler = self
.context
.builder(font.as_swash())
.size(f32::from_bits(cache_key.font_size_bits))
.hint(true)
.variations([("wght", weight as f32)])
.build();
let offset = Vector::new(cache_key.x_bin.as_float(), cache_key.y_bin.as_float());
Render::new(&[
Source::ColorOutline(0),
Source::ColorBitmap(StrikeWith::BestFit),
Source::Outline,
])
.format(Format::Alpha)
.offset(offset)
.transform(
cache_key
.flags
.contains(CacheKeyFlags::FAKE_ITALIC)
.then(|| {
Transform::skew(
Angle::from_degrees(14.0),
Angle::from_degrees(0.0),
)
}),
)
.render(&mut scaler, cache_key.glyph_id)
});
let Some(image) = image else { return };
let left = image.placement.left;
let top = -image.placement.top;
match image.content {
SwashContent::Mask => {
for (index, alpha) in image.data.iter().copied().enumerate() {
let x = index as i32 % image.placement.width as i32;
let y = index as i32 / image.placement.width as i32;
f(
left + x,
top + y,
Color(((alpha as u32) << 24) | base.0 & 0x00FF_FFFF),
);
}
}
SwashContent::Color => {
for (index, rgba) in image.data.chunks_exact(4).enumerate() {
let x = index as i32 % image.placement.width as i32;
let y = index as i32 / image.placement.width as i32;
f(
left + x,
top + y,
Color::rgba(rgba[0], rgba[1], rgba[2], rgba[3]),
);
}
}
SwashContent::SubpixelMask => {}
}
}
}
const REPLACED_CONTEXT_BIT: usize = 1usize << (usize::BITS - 1);
#[derive(Clone, Copy)]
@@ -407,9 +526,6 @@ impl TextEngine {
let shape_weight = face.weight.0;
let italic = declared_italic
.unwrap_or(!matches!(face.style, cosmic_text::fontdb::Style::Normal));
let variable = db
.with_face_data(id, face_is_variable)
.unwrap_or(false);
let weight = declared_weight.unwrap_or((shape_weight, shape_weight));
let declared_names: Vec<String> = declared_family
.map(|name| vec![name])
@@ -424,7 +540,6 @@ impl TextEngine {
max_weight: weight.1,
shape_weight,
italic,
variable,
});
}
}
@@ -434,6 +549,7 @@ impl TextEngine {
font_system,
loaded_families,
swash: SwashCache::new(),
variable_swash: VariableSwashCache::new(),
items: Vec::new(),
replaced: Vec::new(),
}
@@ -458,18 +574,13 @@ impl TextEngine {
}
let base = styles.get(&id)?;
let mut collector = Collector { last_was_space: true, clip_fills: Vec::new() };
let (family, weight) = resolve_loaded_font(
let font = resolve_loaded_font(
base.font_family.as_deref(),
crate::style::used_font_weight(base),
base.font_style_italic.unwrap_or(false),
&self.loaded_families,
);
let ctx = base_span_ctx(
base,
family,
weight,
&mut collector,
);
let ctx = base_span_ctx(base, font, &mut collector);
let mut spans: Vec<(String, SpanAttrs)> = Vec::new();
collect_spans(
tree,
@@ -508,18 +619,13 @@ impl TextEngine {
}
let base = styles.get(&parent)?;
let mut collector = Collector { last_was_space: true, clip_fills: Vec::new() };
let (family, weight) = resolve_loaded_font(
let font = resolve_loaded_font(
base.font_family.as_deref(),
crate::style::used_font_weight(base),
base.font_style_italic.unwrap_or(false),
&self.loaded_families,
);
let ctx = base_span_ctx(
base,
family,
weight,
&mut collector,
);
let ctx = base_span_ctx(base, font, &mut collector);
let mut spans: Vec<(String, SpanAttrs)> = Vec::new();
for &cid in run {
collect_node_spans(
@@ -551,17 +657,18 @@ impl TextEngine {
last_was_space: true,
clip_fills: Vec::new(),
};
let (family, weight) = resolve_loaded_font(
let font = resolve_loaded_font(
style.font_family.as_deref(),
crate::style::used_font_weight(style),
style.font_style_italic.unwrap_or(false),
&self.loaded_families,
);
let context = base_span_ctx(style, family, weight, &mut collector);
let context = base_span_ctx(style, font, &mut collector);
let attrs = SpanAttrs {
font_size: context.font_size,
line_height: context.line_height,
weight: context.weight,
variation_weight: context.variation_weight,
italic: context.italic,
underline: context.underline,
color: context.color,
@@ -735,6 +842,7 @@ struct SpanAttrs {
font_size: f32,
line_height: f32,
weight: u16,
variation_weight: Option<u16>,
italic: bool,
underline: bool,
color: [u8; 4],
@@ -759,9 +867,14 @@ impl SpanAttrs {
// reaches paint; the real gradient is selected through metadata.
let color = if self.clip_fill.is_some() { [255, 255, 255, 255] } else { self.color };
a = a.color(Color::rgba(color[0], color[1], color[2], color[3]));
// Underline and the optional fill index share the per-glyph metadata.
// Underline, the optional fill index, and a variable `wght` coordinate
// share the per-glyph metadata.
let fill = self.clip_fill.map_or(0, |index| (index + 1) << META_FILL_SHIFT);
a = a.metadata(fill | usize::from(self.underline));
debug_assert_eq!(fill & !META_FILL_MASK, 0);
let variation = self
.variation_weight
.map_or(0, |weight| usize::from(weight.clamp(1, 1000)) << META_VARIATION_SHIFT);
a = a.metadata(fill | variation | usize::from(self.underline));
a
}
}
@@ -773,6 +886,7 @@ struct SpanCtx {
line_height: f32,
color: [u8; 4],
weight: u16,
variation_weight: Option<u16>,
italic: bool,
underline: bool,
transform: TextTransform,
@@ -800,8 +914,7 @@ struct Collector {
/// transparent (and invisible), unchanged.
fn base_span_ctx(
base: &LayoutStyle,
family: Arc<str>,
weight: u16,
font: ResolvedFont,
collector: &mut Collector,
) -> SpanCtx {
let clip_fill = clip_text_fill(base).map(|fill| {
@@ -813,11 +926,12 @@ fn base_span_ctx(
font_size: base.font_size.unwrap_or(16.0),
line_height: used_line_height(base),
color: base.color.unwrap_or([0, 0, 0, 255]),
weight,
weight: font.shape_weight,
variation_weight: font.variation_weight,
italic: base.font_style_italic.unwrap_or(false),
underline: base.underline.unwrap_or(false),
transform: base.text_transform.unwrap_or(TextTransform::None),
family,
family: font.family,
clip_fill,
}
}
@@ -864,6 +978,7 @@ fn collect_node_spans(
font_size: ctx.font_size,
line_height: ctx.line_height,
weight: ctx.weight,
variation_weight: ctx.variation_weight,
italic: ctx.italic,
underline: ctx.underline,
color: ctx.color,
@@ -883,6 +998,7 @@ fn collect_node_spans(
font_size: ctx.font_size,
line_height: ctx.line_height,
weight: ctx.weight,
variation_weight: ctx.variation_weight,
italic: ctx.italic,
underline: ctx.underline,
color: ctx.color,
@@ -907,7 +1023,7 @@ fn collect_node_spans(
let requested_weight = style
.map(crate::style::used_font_weight)
.unwrap_or(ctx.weight);
let (family, weight) = style
let font = style
.and_then(|style| style.font_family.as_deref())
.map(|family| {
resolve_loaded_font(
@@ -919,7 +1035,17 @@ fn collect_node_spans(
loaded_families,
)
})
.unwrap_or_else(|| (Arc::clone(&ctx.family), requested_weight));
.unwrap_or_else(|| ResolvedFont {
family: Arc::clone(&ctx.family),
shape_weight: if ctx.variation_weight.is_some() {
ctx.weight
} else {
requested_weight
},
variation_weight: ctx
.variation_weight
.map(|_| requested_weight.clamp(1, 1000)),
});
let child = SpanCtx {
font_size: style
.and_then(|style| style.font_size)
@@ -928,13 +1054,14 @@ fn collect_node_spans(
.map(used_line_height)
.unwrap_or(ctx.line_height),
color,
weight,
weight: font.shape_weight,
variation_weight: font.variation_weight,
italic: ctx.italic || style.and_then(|s| s.font_style_italic).unwrap_or(false),
// 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),
transform: style.and_then(|s| s.text_transform).unwrap_or(ctx.transform),
family,
family: font.family,
clip_fill,
};
collect_spans(
@@ -998,8 +1125,17 @@ fn buffer_size(buffer: &Buffer) -> (f32, f32) {
/// plain inline formatting (no atomic inline boxes, no block children, no
/// inline element carrying its own box)? Such a container collapses cleanly
/// to one shaped buffer; anything else keeps the general build path.
fn is_pure_text_ifc(tree: &DomTree, id: NodeId, styles: &std::collections::HashMap<NodeId, LayoutStyle>) -> bool {
let Some(style) = styles.get(&id) else { return false };
fn is_pure_text_ifc(
tree: &DomTree,
id: NodeId,
styles: &std::collections::HashMap<NodeId, LayoutStyle>,
) -> bool {
let Some(style) = styles.get(&id) else {
return false;
};
if style.before_pseudo.is_some() || style.after_pseudo.is_some() {
return false;
}
// Only containers that lay their children out in normal flow (block, or
// the flex-column stand-ins our UA sheet uses for td/th/center). A real
// flex/grid row with inline children is rare and better left to taffy.
@@ -1080,8 +1216,8 @@ fn inline_child_ok(tree: &DomTree, cid: NodeId, styles: &std::collections::HashM
&& style.position.is_none()
&& style.float.is_none()
&& !style.overflow_hidden
&& style.before_content.is_none()
&& style.after_content.is_none();
&& style.before_pseudo.is_none()
&& style.after_pseudo.is_none();
if !foldable_inline {
return false;
}
@@ -1177,6 +1313,7 @@ impl TextEngine {
let TextEngine {
font_system,
swash,
variable_swash,
items,
..
} = self;
@@ -1203,7 +1340,7 @@ impl TextEngine {
let mut seg: Option<(f32, f32, f32, [u8; 4])> = None; // x0, x1, font_size, color
for g in run.glyphs {
let underlined = g.metadata & META_UNDERLINE != 0;
if let Some(fill_index) = (g.metadata >> META_FILL_SHIFT).checked_sub(1) {
if let Some(fill_index) = metadata_fill(g.metadata) {
if let Some(bounds) = fill_bounds.get_mut(fill_index) {
let glyph_bounds = (
g.x,
@@ -1259,12 +1396,8 @@ impl TextEngine {
for glyph in run.glyphs {
let physical = glyph.physical((0.0, 0.0), 1.0);
let glyph_color = glyph.color_opt.unwrap_or(default);
let fill_index = (glyph.metadata >> META_FILL_SHIFT).checked_sub(1);
swash.with_pixels(
font_system,
physical.cache_key,
glyph_color,
|x, y, color| {
let fill_index = metadata_fill(glyph.metadata);
let mut draw_pixel = |x, y, color: Color| {
let a = color.a() as u32;
if a == 0 {
return;
@@ -1311,8 +1444,23 @@ impl TextEngine {
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)
.unwrap_or(dst);
},
);
};
if let Some(weight) = metadata_variation_weight(glyph.metadata) {
variable_swash.with_pixels(
font_system,
physical.cache_key,
weight,
glyph_color,
&mut draw_pixel,
);
} else {
swash.with_pixels(
font_system,
physical.cache_key,
glyph_color,
&mut draw_pixel,
);
}
}
}
@@ -1349,6 +1497,7 @@ impl TextEngine {
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine as _;
const RED: [u8; 4] = [255, 0, 0, 255];
const BLUE: [u8; 4] = [0, 0, 255, 255];
@@ -1383,7 +1532,6 @@ mod tests {
max_weight: 400,
shape_weight: 400,
italic: false,
variable: false,
},
LoadedFace {
name: Arc::from("Poppins Medium"),
@@ -1391,7 +1539,6 @@ mod tests {
max_weight: 500,
shape_weight: 500,
italic: false,
variable: false,
},
LoadedFace {
name: Arc::from("Poppins"),
@@ -1399,17 +1546,203 @@ mod tests {
max_weight: 700,
shape_weight: 700,
italic: false,
variable: false,
},
],
};
let loaded = HashMap::from([("poppins".to_string(), family)]);
let medium = resolve_loaded_font(Some("Poppins, sans-serif"), 500, false, &loaded);
assert_eq!(medium.0.as_ref(), "Poppins Medium");
assert_eq!(medium.1, 500);
assert_eq!(medium.family.as_ref(), "Poppins Medium");
assert_eq!(medium.shape_weight, 500);
assert_eq!(medium.variation_weight, None);
let semibold = resolve_loaded_font(Some("Poppins"), 600, false, &loaded);
assert_eq!(semibold.0.as_ref(), "Poppins");
assert_eq!(semibold.1, 700);
assert_eq!(semibold.family.as_ref(), "Poppins");
assert_eq!(semibold.shape_weight, 700);
assert_eq!(semibold.variation_weight, None);
}
#[test]
fn ranged_face_shapes_at_its_font_database_weight() {
let tree = obscura_dom::parse_html("<p id='copy'>Variable family</p>");
let copy = tree.get_element_by_id("copy").unwrap();
let mut style = LayoutStyle::default();
style.display = Display::Block;
style.font_family = Some("test variable".to_string());
style.font_weight = Some("700".to_string());
style.font_size = Some(32.0);
let styles = HashMap::from([(copy, style)]);
let mut engine = TextEngine::new();
engine.loaded_families.insert(
"test variable".to_string(),
LoadedFamily {
faces: vec![LoadedFace {
name: Arc::from(FAMILY),
min_weight: 100,
max_weight: 900,
shape_weight: 400,
italic: false,
}],
},
);
let item = engine.try_build(&tree, copy, &styles).unwrap();
engine.measure(item, None);
let font_id = engine.items[item]
.buffer
.layout_runs()
.next()
.and_then(|run| run.glyphs.first())
.map(|glyph| glyph.font_id)
.unwrap();
let face = engine.font_system.db().face(font_id).unwrap();
assert!(
face.families.iter().any(|(name, _)| name == FAMILY),
"authored family must not fall through to an unrelated exact-weight face"
);
assert_eq!(face.weight.0, 400);
}
#[test]
fn ranged_face_preserves_requested_weight_for_variable_raster() {
let loaded = HashMap::from([(
"inter".to_string(),
LoadedFamily {
faces: vec![LoadedFace {
name: Arc::from("Inter"),
min_weight: 100,
max_weight: 900,
shape_weight: 400,
italic: false,
}],
},
)]);
let resolved = resolve_loaded_font(Some("Inter, sans-serif"), 725, false, &loaded);
assert_eq!(resolved.family.as_ref(), "Inter");
assert_eq!(resolved.shape_weight, 400);
assert_eq!(resolved.variation_weight, Some(725));
}
#[test]
fn glyph_metadata_keeps_fill_and_variable_weight_independent() {
let attrs = SpanAttrs {
font_size: 16.0,
line_height: 18.0,
weight: 400,
variation_weight: Some(725),
italic: false,
underline: true,
color: [1, 2, 3, 255],
family: Arc::from(FAMILY),
clip_fill: Some(37),
};
let metadata = attrs.to_attrs().metadata;
assert_ne!(metadata & META_UNDERLINE, 0);
assert_eq!(metadata_fill(metadata), Some(37));
assert_eq!(metadata_variation_weight(metadata), Some(725));
}
fn variable_font_fixture() -> Vec<u8> {
let encoded: String = include_str!("../tests/fonts/obscura-vf-test.woff2.b64")
.chars()
.filter(|ch| !ch.is_whitespace())
.collect();
let compressed = base64::engine::general_purpose::STANDARD
.decode(encoded)
.expect("decode variable-font fixture");
wuff::decompress_woff2(&compressed).expect("decompress variable-font fixture")
}
fn render_variable_weight(weight: u16) -> ((f32, f32), u64, Vec<u16>) {
let mut engine = TextEngine::new_with_web_fonts(&[WebFont {
data: variable_font_fixture(),
family: Some("Obscura VF Test".to_string()),
weight: Some((100, 900)),
italic: Some(false),
}]);
let tree = obscura_dom::parse_html("<p id='copy'>MMMMMMMM</p>");
let copy = tree.get_element_by_id("copy").unwrap();
let mut style = LayoutStyle::default();
style.display = Display::Block;
style.font_family = Some("Obscura VF Test".to_string());
style.font_weight = Some(weight.to_string());
style.font_size = Some(64.0);
style.line_height = Some(crate::LineHeight::Px(80.0));
let styles = HashMap::from([(copy, style)]);
let item = engine.try_build(&tree, copy, &styles).unwrap();
let geometry = engine.measure(item, Some(600.0));
engine.finalize(item, (0.0, 0.0), 600.0, None);
let mut pixmap = tiny_skia::Pixmap::new(600, 100).unwrap();
engine.paint_item(item, &mut pixmap, (0.0, 0.0));
let ink = pixmap
.pixels()
.iter()
.map(|pixel| u64::from(pixel.alpha()))
.sum();
let mut cached_weights: Vec<_> = engine
.variable_swash
.images
.keys()
.map(|key| key.weight)
.collect();
cached_weights.sort_unstable();
cached_weights.dedup();
(geometry, ink, cached_weights)
}
#[test]
fn variable_wght_changes_true_outline_without_changing_layout_geometry() {
let (regular_geometry, regular_ink, regular_cache) = render_variable_weight(400);
let (black_geometry, black_ink, black_cache) = render_variable_weight(900);
assert_eq!(regular_geometry, black_geometry);
assert!(
black_ink > regular_ink * 13 / 10,
"wght=900 should carry substantially more raster ink: {regular_ink} vs {black_ink}"
);
assert_eq!(regular_cache, vec![400]);
assert_eq!(black_cache, vec![900]);
}
#[test]
fn variable_glyph_cache_keys_include_weight_axis() {
let mut engine = TextEngine::new_with_web_fonts(&[WebFont {
data: variable_font_fixture(),
family: Some("Obscura VF Test".to_string()),
weight: Some((100, 900)),
italic: Some(false),
}]);
let tree = obscura_dom::parse_html(
"<p id='copy'><span id='regular'>M</span><span id='black'>M</span></p>",
);
let copy = tree.get_element_by_id("copy").unwrap();
let regular = tree.get_element_by_id("regular").unwrap();
let black = tree.get_element_by_id("black").unwrap();
let mut base = LayoutStyle::default();
base.display = Display::Block;
base.font_family = Some("Obscura VF Test".to_string());
base.font_size = Some(64.0);
base.line_height = Some(crate::LineHeight::Px(80.0));
let mut regular_style = base.clone();
regular_style.display = Display::Inline;
regular_style.font_weight = Some("400".to_string());
let mut black_style = regular_style.clone();
black_style.font_weight = Some("900".to_string());
let styles = HashMap::from([
(copy, base),
(regular, regular_style),
(black, black_style),
]);
let item = engine.try_build(&tree, copy, &styles).unwrap();
engine.measure(item, Some(200.0));
engine.finalize(item, (0.0, 0.0), 200.0, None);
let mut pixmap = tiny_skia::Pixmap::new(200, 100).unwrap();
engine.paint_item(item, &mut pixmap, (0.0, 0.0));
let weights: std::collections::HashSet<_> = engine
.variable_swash
.images
.keys()
.map(|key| key.weight)
.collect();
assert_eq!(weights, std::collections::HashSet::from([400, 900]));
}
#[test]
+42 -8
View File
@@ -20,12 +20,14 @@ pub mod style;
pub use style::compute_style;
pub mod dom;
pub use dom::{layout_dom, layout_dom_with_images, layout_dom_with_resources, DomLayout};
pub use dom::{
layout_dom, layout_dom_with_images, layout_dom_with_resources, DomLayout, StickyLayout,
};
#[cfg(feature = "paint")]
mod paint;
#[cfg(feature = "paint")]
pub use paint::{paint_dom, screenshot_png};
pub use paint::{paint_dom, paint_dom_scrolled, screenshot_png, screenshot_png_scrolled};
// Real inline text layout (cosmic-text) lives behind the paint feature; the
// layout-only build keeps the lighter word-split geometry. The stub lets
@@ -301,7 +303,19 @@ pub struct LayoutStyle {
pub mask_repeat: Option<(bool, bool)>,
/// Foreground (text) color for the paint step.
pub color: Option<[u8; 4]>,
/// Computed SVG presentation properties supplied by author CSS. Inline
/// SVG is serialized into a standalone document for resvg; without
/// carrying these values across that boundary, stylesheet-driven icons
/// and text lose their fill/stroke and can become completely invisible.
pub svg_fill: Option<String>,
pub svg_stroke: Option<String>,
pub svg_stroke_width: Option<String>,
pub border_color: Option<[u8; 4]>,
/// Used color scheme for CSS Color 5 `light-dark()`. The renderer's
/// current user preference is light; an inherited `color-scheme: dark`
/// subtree switches this to true, while `normal`, `light`, or a list that
/// permits light keeps the light scheme.
pub color_scheme_dark: bool,
pub font_size: Option<f32>,
/// `font-size` given in a font/viewport-relative unit, resolved to
/// `font_size` (px) during the inheritance pass against the parent and
@@ -391,6 +405,10 @@ pub struct LayoutStyle {
/// Distinguishes `fixed` from `absolute`; both map to taffy's absolute
/// layout mode, but fixed boxes use the initial containing block.
pub position_fixed: bool,
/// Distinguishes `sticky` from ordinary relative positioning. Sticky boxes
/// remain in normal flow; their insets constrain a scroll-time translation
/// and therefore must not be applied as taffy relative offsets.
pub position_sticky: bool,
pub inset: [Option<Dimension>; 4], // top, right, bottom, left
/// Deferred functional inset expressions in top/right/bottom/left order.
pub inset_expressions: [Option<String>; 4],
@@ -401,6 +419,15 @@ 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,
/// 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.
pub overflow_scroll_container: bool,
/// Number of classic scrollbar gutters reserved by
/// `scrollbar-gutter:stable` (one) or `stable both-edges` (two). Root
/// gutters reduce the initial containing block even when the scrollbar
/// itself is visually hidden in a headless screenshot.
pub scrollbar_gutters: u8,
/// `float: left|right`. True CSS float needs per-line reflow around the
/// float's shape, which taffy's block/flex/grid modes do not do; see
@@ -445,6 +472,11 @@ pub struct LayoutStyle {
/// compositing.
pub effectively_invisible: bool,
/// A CSS image supplied by `content: url(...)` on a replaced element.
/// This is distinct from generated pseudo text: on an `<img>` it becomes
/// the element's image source and contributes intrinsic dimensions just
/// like an HTML `src`.
pub content_image: Option<String>,
/// Literal text injected by a `::before`/`::after` rule with a plain
/// string-literal `content` (see `css::Stylesheet::pseudo_content`).
/// Rendered as an extra word-run at the start/end of this element's
@@ -863,12 +895,14 @@ pub(crate) fn to_taffy_style(style: &LayoutStyle) -> Style {
// Positioning. Absolute/fixed take the box out of flow.
if let Some(pos) = style.position {
s.position = pos;
s.inset = taffy::Rect {
top: inset_lpa(style.inset[0]),
right: inset_lpa(style.inset[1]),
bottom: inset_lpa(style.inset[2]),
left: inset_lpa(style.inset[3]),
};
if !style.position_sticky {
s.inset = taffy::Rect {
top: inset_lpa(style.inset[0]),
right: inset_lpa(style.inset[1]),
bottom: inset_lpa(style.inset[2]),
left: inset_lpa(style.inset[3]),
};
}
}
s.margin = rect_auto(style.margin, style.margin_auto);
File diff suppressed because it is too large. Load diff
+601 -46
View File
@@ -237,13 +237,16 @@ pub fn ua_style(tag: &str) -> LayoutStyle {
pub fn apply_inline(style: &mut LayoutStyle, css: &str) {
let (normal, important) = partition_declarations(css);
apply_declarations(style, &normal);
apply_declarations(style, &important);
let inherited_scheme = style.color_scheme_dark;
apply_color_scheme_declarations_from(style, &normal, inherited_scheme);
apply_color_scheme_declarations_from(style, &important, inherited_scheme);
apply_declarations_with_locked_color_scheme(style, &normal);
apply_declarations_with_locked_color_scheme(style, &important);
}
/// Split a declaration block into normal and `!important` declarations while
/// preserving source order inside each priority. The returned declarations
/// have the priority marker removed, ready for [`apply_declarations`].
/// have the priority marker removed, ready for the computed-style pass.
pub(crate) fn partition_declarations(css: &str) -> (String, String) {
let mut normal = String::new();
let mut important = String::new();
@@ -270,16 +273,72 @@ pub(crate) fn partition_declarations(css: &str) -> (String, String) {
(normal, important)
}
/// Apply declarations in the order provided. Priority ordering must already
/// have been resolved by the caller.
pub(crate) fn apply_declarations(style: &mut LayoutStyle, css: &str) {
/// Apply only `color-scheme` from a declaration list. The stylesheet cascade
/// runs this over every matching block before resolving color-valued
/// declarations, because `light-dark()` selects from the element's final used
/// scheme regardless of whether `color-scheme` appears before or after the
/// color in source order.
pub(crate) fn apply_color_scheme_declarations_from(
style: &mut LayoutStyle,
css: &str,
inherited_scheme: bool,
) {
for raw in split_declarations(css) {
let Some((name, value)) = raw.trim().split_once(':') else { continue };
if name.trim().eq_ignore_ascii_case("color-scheme") {
apply_color_scheme(style, value.trim(), inherited_scheme);
}
}
}
pub(crate) fn apply_declarations_with_locked_color_scheme(
style: &mut LayoutStyle,
css: &str,
) {
for raw in split_declarations(css) {
let decl = raw.trim();
if decl.is_empty() {
continue;
}
let Some((name, value)) = decl.split_once(':') else { continue };
apply_value(style, &name.trim().to_ascii_lowercase(), value.trim());
let Some((name, value)) = decl.split_once(':') else {
continue;
};
let name = name.trim().to_ascii_lowercase();
if name != "color-scheme" {
apply_value(style, &name, value.trim());
}
}
}
fn apply_color_scheme(
style: &mut LayoutStyle,
value: &str,
inherited_scheme: bool,
) {
let tokens: Vec<String> = value
.split_whitespace()
.map(|token| token.to_ascii_lowercase())
.collect();
if tokens.iter().any(|token| token == "inherit" || token == "unset") {
style.color_scheme_dark = inherited_scheme;
return;
}
if tokens.iter().any(|token| {
matches!(token.as_str(), "initial" | "revert" | "revert-layer")
}) {
style.color_scheme_dark = false;
return;
}
// The current browser/user preference is light. A scheme list that admits
// light therefore uses light; a dark-only list uses dark. `normal`,
// initial/revert, and malformed values retain the default/inherited light
// behavior used by this compact computed-style model.
if tokens.iter().any(|token| token == "light")
|| tokens.iter().any(|token| token == "normal")
{
style.color_scheme_dark = false;
} else if tokens.iter().any(|token| token == "dark") {
style.color_scheme_dark = true;
}
}
@@ -448,8 +507,10 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
style.border_color = None;
return;
}
for p in value.split_whitespace() {
if let Some(c) = parse_color(p) {
for p in split_ws_paren(value) {
if let Some(c) =
parse_color_for_scheme(p, style.color_scheme_dark)
{
style.border_color = Some(c);
} else if p.ends_with("px") || p.chars().all(|c| c.is_ascii_digit()) {
if let Some(e) = edges(p) { style.border = e; }
@@ -469,7 +530,10 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
"border-left-width" | "border-left" => {
set_edge(&mut style.border, Side::Left, border_side_width(value))
}
"background-color" => style.background_color = parse_color(value),
"background-color" => {
style.background_color =
parse_color_for_scheme(value, style.color_scheme_dark)
}
"background" => {
// A shorthand resets every omitted background longhand to its
// initial value before applying the layers it does name.
@@ -480,14 +544,18 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
// so leave the prior cascade winner untouched in that case.
if !value.trim().is_empty() {
style.background_color = None;
style.background_gradient = parse_linear_gradient(value);
style.background_radial_gradient = parse_radial_gradient(value);
style.background_conic_gradient = parse_conic_gradient(value);
style.background_gradient =
parse_linear_gradient(value, style.color_scheme_dark);
style.background_radial_gradient =
parse_radial_gradient(value, style.color_scheme_dark);
style.background_conic_gradient =
parse_conic_gradient(value, style.color_scheme_dark);
if style.background_gradient.is_none()
&& style.background_radial_gradient.is_none()
&& style.background_conic_gradient.is_none()
{
style.background_color = parse_color(value);
style.background_color =
parse_color_for_scheme(value, style.color_scheme_dark);
}
style.background_image = parse_url(value);
style.background_size = None;
@@ -498,9 +566,12 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
}
}
"background-image" => {
style.background_gradient = parse_linear_gradient(value);
style.background_radial_gradient = parse_radial_gradient(value);
style.background_conic_gradient = parse_conic_gradient(value);
style.background_gradient =
parse_linear_gradient(value, style.color_scheme_dark);
style.background_radial_gradient =
parse_radial_gradient(value, style.color_scheme_dark);
style.background_conic_gradient =
parse_conic_gradient(value, style.color_scheme_dark);
style.background_image = parse_url(value);
}
"background-size" => {
@@ -510,6 +581,10 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
style.background_size_fit = parse_background_size_fit(value);
}
"background-position" => style.background_position = parse_background_position(value),
// On replaced elements, an image-valued `content` replaces the
// ordinary source and participates in intrinsic sizing. String-valued
// generated content is handled separately for pseudo-elements.
"content" => style.content_image = parse_url(value),
"mask-image" | "-webkit-mask-image" => style.mask_image = parse_url(value),
"mask-size" | "-webkit-mask-size" => {
style.mask_size = parse_background_size(value)
@@ -533,8 +608,30 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
// transparent through this inherited property while clipping a
// background gradient to the text. We model one effective text color,
// so the vendor fill color is the paint-time color winner.
"color" | "-webkit-text-fill-color" => style.color = parse_color(value),
"border-color" => style.border_color = parse_color(value),
"color" | "-webkit-text-fill-color" => {
style.color = parse_color_for_scheme(value, style.color_scheme_dark)
}
// Inline SVG is rasterized as a standalone SVG document. Preserve
// author-CSS presentation values verbatim so paint can carry the
// computed cascade into that document (including currentColor,
// `none`, and paint-server URLs).
"fill" => {
style.svg_fill = Some(resolve_svg_presentation_color(
value,
style.color_scheme_dark,
))
}
"stroke" => {
style.svg_stroke = Some(resolve_svg_presentation_color(
value,
style.color_scheme_dark,
))
}
"stroke-width" => style.svg_stroke_width = Some(value.trim().to_string()),
"border-color" => {
style.border_color =
parse_color_for_scheme(value, style.color_scheme_dark)
}
"font-size" => {
// Absolute lengths resolve now; font/viewport-relative ones defer
// to the inheritance pass (they need parent/root font-size).
@@ -654,18 +751,27 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
"absolute" => {
style.position = Some(taffy::Position::Absolute);
style.position_fixed = false;
style.position_sticky = false;
}
"fixed" => {
style.position = Some(taffy::Position::Absolute);
style.position_fixed = true;
style.position_sticky = false;
}
"relative" | "sticky" => {
"relative" => {
style.position = Some(taffy::Position::Relative);
style.position_fixed = false;
style.position_sticky = false;
}
"sticky" => {
style.position = Some(taffy::Position::Relative);
style.position_fixed = false;
style.position_sticky = true;
}
"static" => {
style.position = None;
style.position_fixed = false;
style.position_sticky = false;
}
_ => {}
}
@@ -709,6 +815,26 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
}
"overflow" | "overflow-x" | "overflow-y" => {
style.overflow_hidden = value != "visible";
style.overflow_scroll_container =
!matches!(value, "visible" | "clip");
}
"scrollbar-gutter" => {
let lower = value.to_ascii_lowercase();
style.scrollbar_gutters = if lower
.split_ascii_whitespace()
.any(|token| token == "stable")
{
if lower
.split_ascii_whitespace()
.any(|token| token == "both-edges")
{
2
} else {
1
}
} else {
0
};
}
"visibility" => style.visibility_hidden = Some(value.eq_ignore_ascii_case("hidden")),
"opacity" => style.opacity = value.trim().parse::<f32>().ok(),
@@ -905,12 +1031,247 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
value.trim().eq_ignore_ascii_case("auto"),
),
"box-shadow" | "-webkit-box-shadow" => {
style.box_shadow = parse_box_shadow(value, style.color);
style.box_shadow = parse_box_shadow(
value,
style.color,
style.color_scheme_dark,
);
}
_ => {}
}
}
/// Whether a declaration is syntactically supported by the renderer's CSS
/// property/value implementation. `@supports` and the JavaScript-facing
/// capability model must not treat every non-empty declaration as valid:
/// modern framework sheets use negative feature probes to isolate legacy
/// browser fallbacks, and activating those fallbacks corrupts the modern
/// cascade.
pub(crate) fn supports_declaration(name: &str, value: &str) -> bool {
let name = name.trim().to_ascii_lowercase();
let value = value.trim();
if value.is_empty() {
return false;
}
if name.starts_with("--") {
return name.len() > 2;
}
let css_wide = matches!(
value.to_ascii_lowercase().as_str(),
"initial" | "inherit" | "unset" | "revert" | "revert-layer"
);
let known = matches!(
name.as_str(),
"display"
| "width"
| "height"
| "min-width"
| "min-height"
| "max-width"
| "max-height"
| "box-sizing"
| "aspect-ratio"
| "margin"
| "margin-top"
| "margin-right"
| "margin-bottom"
| "margin-left"
| "margin-inline"
| "margin-inline-start"
| "margin-inline-end"
| "margin-block"
| "margin-block-start"
| "margin-block-end"
| "padding"
| "padding-top"
| "padding-right"
| "padding-bottom"
| "padding-left"
| "padding-inline"
| "padding-inline-start"
| "padding-inline-end"
| "padding-block"
| "padding-block-start"
| "padding-block-end"
| "border-radius"
| "border"
| "border-width"
| "border-top-width"
| "border-right-width"
| "border-bottom-width"
| "border-left-width"
| "border-top"
| "border-right"
| "border-bottom"
| "border-left"
| "background"
| "background-color"
| "background-image"
| "background-size"
| "background-position"
| "background-clip"
| "-webkit-background-clip"
| "mask-image"
| "-webkit-mask-image"
| "mask-size"
| "-webkit-mask-size"
| "mask-repeat"
| "-webkit-mask-repeat"
| "color"
| "-webkit-text-fill-color"
| "fill"
| "stroke"
| "stroke-width"
| "border-color"
| "color-scheme"
| "font-size"
| "font"
| "font-weight"
| "font-family"
| "font-style"
| "text-align"
| "text-transform"
| "text-decoration"
| "text-decoration-line"
| "line-height"
| "align-items"
| "justify-items"
| "place-items"
| "align-self"
| "justify-self"
| "place-self"
| "align-content"
| "justify-content"
| "place-content"
| "flex-direction"
| "flex-wrap"
| "flex-grow"
| "flex-shrink"
| "flex-basis"
| "flex"
| "order"
| "position"
| "float"
| "object-fit"
| "top"
| "right"
| "bottom"
| "left"
| "inset"
| "overflow"
| "overflow-x"
| "overflow-y"
| "scrollbar-gutter"
| "visibility"
| "opacity"
| "animation"
| "animation-name"
| "animation-fill-mode"
| "animation-iteration-count"
| "z-index"
| "clear"
| "vertical-align"
| "list-style"
| "list-style-type"
| "gap"
| "grid-gap"
| "row-gap"
| "grid-row-gap"
| "column-gap"
| "grid-column-gap"
| "border-spacing"
| "border-collapse"
| "grid-template-columns"
| "grid-template-rows"
| "grid-template-areas"
| "grid-template"
| "grid"
| "grid-auto-flow"
| "grid-area"
| "grid-column"
| "grid-row"
| "grid-column-start"
| "grid-column-end"
| "grid-row-start"
| "grid-row-end"
| "transform"
| "filter"
| "backdrop-filter"
| "-webkit-backdrop-filter"
| "perspective"
| "contain"
| "will-change"
| "content-visibility"
| "box-shadow"
| "-webkit-box-shadow"
);
if !known {
return false;
}
if css_wide {
return true;
}
match name.as_str() {
"display" => matches!(
value.to_ascii_lowercase().as_str(),
"none"
| "flex"
| "inline-flex"
| "inline"
| "inline-block"
| "grid"
| "inline-grid"
| "block"
| "flow-root"
| "contents"
),
"position" => matches!(
value.to_ascii_lowercase().as_str(),
"static" | "relative" | "absolute" | "fixed" | "sticky"
),
"box-sizing" => matches!(
value.to_ascii_lowercase().as_str(),
"content-box" | "border-box"
),
"float" => matches!(
value.to_ascii_lowercase().as_str(),
"none" | "left" | "right"
),
"object-fit" => matches!(
value.to_ascii_lowercase().as_str(),
"fill" | "contain" | "cover" | "none" | "scale-down"
),
"visibility" => matches!(
value.to_ascii_lowercase().as_str(),
"visible" | "hidden" | "collapse"
),
"scrollbar-gutter" => matches!(
value.to_ascii_lowercase().as_str(),
"auto" | "stable" | "stable both-edges"
),
"color" | "-webkit-text-fill-color" | "background-color" | "border-color" => {
parse_color(value).is_some()
}
"color-scheme" => {
let tokens: Vec<&str> = value.split_whitespace().collect();
!tokens.is_empty()
&& tokens.iter().all(|token| {
matches!(
token.to_ascii_lowercase().as_str(),
"normal" | "light" | "dark" | "only"
)
})
&& tokens.iter().any(|token| {
matches!(
token.to_ascii_lowercase().as_str(),
"normal" | "light" | "dark"
)
})
}
_ => true,
}
}
fn apply_animation_shorthand(style: &mut LayoutStyle, value: &str) {
style.animation_name = None;
style.animation_fill_forwards = false;
@@ -1509,13 +1870,20 @@ fn set_grid_placement_side(
} else {
&mut style.grid_row_raw
};
let (mut start, mut end) = raw_slot
.as_deref()
.and_then(|raw| raw.split_once('/'))
.map(|(start, end)| {
let (mut start, mut end) = match raw_slot.as_deref() {
Some(raw) if raw.contains('/') => {
let (start, end) = raw.split_once('/').unwrap();
(start.trim().to_string(), end.trim().to_string())
})
.unwrap_or_else(|| ("auto".to_string(), "auto".to_string()));
}
// A single named grid-area shorthand contributes its `-start`
// and `-end` lines. Preserve both logical sides when a later
// longhand overrides only one of them (`grid-column:content`
// followed by `grid-column-end:extended-full-end`, as on MDN).
// Resolution can still fall back to an exact named line when no
// area-generated name exists.
Some(raw) => (raw.trim().to_string(), raw.trim().to_string()),
None => ("auto".to_string(), "auto".to_string()),
};
if is_start {
start = value.trim().to_string();
} else {
@@ -1591,19 +1959,51 @@ fn parse_grid_placement(value: &str) -> taffy::GridPlacement {
/// rgb()/rgba(), `var(--x, fallback)` (uses the fallback), and a set of named
/// colors. Returns None for anything else (transparent).
pub(crate) fn parse_color(value: &str) -> Option<[u8; 4]> {
parse_color_for_scheme(value, false)
}
fn parse_color_for_scheme(value: &str, dark_scheme: bool) -> Option<[u8; 4]> {
let raw = value.trim();
// CSS Color 5 `light-dark(light, dark)` selects by the used color scheme.
// Obscura currently exposes the default/light scheme, so return the first
// branch. Both branches still have to be complete valid colors: accepting
// a valid light arm beside malformed dark syntax would keep a declaration
// that Chromium rejects at parse time.
let lower_full = raw.to_ascii_lowercase();
if lower_full.starts_with("light-dark(") {
let inner_and_close = &raw["light-dark(".len()..];
let close = find_matching_paren(inner_and_close)?;
if !inner_and_close[close + 1..].trim().is_empty() {
return None;
}
let arguments = split_top_level(&inner_and_close[..close], ',');
if arguments.len() != 2
|| arguments
.iter()
.any(|argument| argument.trim().is_empty() || !is_complete_color_token(argument))
{
return None;
}
let light =
parse_color_for_scheme(arguments[0].trim(), dark_scheme)?;
let dark =
parse_color_for_scheme(arguments[1].trim(), dark_scheme)?;
return Some(if dark_scheme { dark } else { light });
}
// CSS custom property with a fallback: var(--name, <fallback>). We cannot
// resolve the variable, but the fallback after the comma is a real color.
if let Some(rest) = raw.strip_prefix("var(") {
let inner = rest.strip_suffix(')').unwrap_or(rest);
if let Some((_, fallback)) = inner.split_once(',') {
return parse_color(fallback.trim());
return parse_color_for_scheme(fallback.trim(), dark_scheme);
}
return None;
}
// rgb()/rgba() functional notation.
let lower_full = raw.to_ascii_lowercase();
if let Some(rest) = lower_full.strip_prefix("rgb(").or_else(|| lower_full.strip_prefix("rgba(")) {
if let Some(rest) = lower_full
.strip_prefix("rgb(")
.or_else(|| lower_full.strip_prefix("rgba("))
{
let inner = rest.strip_suffix(')').unwrap_or(rest);
let parts: Vec<&str> = inner.split([',', '/', ' ']).filter(|p| !p.trim().is_empty()).collect();
if parts.len() >= 3 {
@@ -1690,10 +2090,14 @@ pub(crate) fn parse_color(value: &str) -> Option<[u8; 4]> {
if let Some(idx) = s.rfind(char::is_whitespace) {
let tail = s[idx + 1..].trim();
if let Some(p) = tail.strip_suffix('%').and_then(|x| x.parse::<f32>().ok()) {
return parse_color(s[..idx].trim()).map(|c| (c, Some(p / 100.0)));
return parse_color_for_scheme(
s[..idx].trim(),
dark_scheme,
)
.map(|c| (c, Some(p / 100.0)));
}
}
parse_color(s).map(|c| (c, None))
parse_color_for_scheme(s, dark_scheme).map(|c| (c, None))
};
if let (Some((c1, p1)), Some((c2, p2))) = (parse_arg(args[1]), parse_arg(args[2])) {
let (w1, w2) = match (p1, p2) {
@@ -1777,6 +2181,18 @@ pub(crate) fn parse_color(value: &str) -> Option<[u8; 4]> {
}
}
fn resolve_svg_presentation_color(value: &str, dark_scheme: bool) -> String {
let raw = value.trim();
if raw.to_ascii_lowercase().contains("light-dark(") {
if let Some([red, green, blue, alpha]) =
parse_color_for_scheme(raw, dark_scheme)
{
return format!("#{red:02x}{green:02x}{blue:02x}{alpha:02x}");
}
}
raw.to_string()
}
/// The remaining common CSS named colors (the hot ones from real sites) beyond
/// the handful spelled out above.
fn named_color(v: &str) -> Option<[u8; 4]> {
@@ -1890,6 +2306,52 @@ fn named_color(v: &str) -> Option<[u8; 4]> {
Some([rgb[0], rgb[1], rgb[2], 255])
}
/// Reject trailing tokens and unbalanced functions before a `light-dark()`
/// branch reaches the intentionally permissive legacy color parser. Nested
/// color functions and their internal whitespace remain valid.
fn is_complete_color_token(value: &str) -> bool {
let value = value.trim();
if value.is_empty() {
return false;
}
let mut depth = 0i32;
let mut first_open = None;
let mut outer_close = None;
for (index, character) in value.char_indices() {
match character {
'(' => {
if depth == 0 && first_open.is_none() {
first_open = Some(index);
}
depth += 1;
}
')' => {
depth -= 1;
if depth < 0 {
return false;
}
if depth == 0 {
outer_close = Some(index);
}
}
_ => {}
}
}
if depth != 0 {
return false;
}
match first_open {
Some(open) => {
open > 0
&& value[..open]
.chars()
.all(|character| character.is_ascii_alphanumeric() || character == '-')
&& outer_close == Some(value.len() - 1)
}
None => !value.chars().any(char::is_whitespace),
}
}
/// Convert `hsl()`/`hsla()` to RGBA. `h` in degrees, `s`/`l` as 0-1.
/// Convert an OKLab color (L in 0..1, a/b unbounded) to sRGB rgba bytes.
/// (oklch is converted to oklab by the caller.) Standard Björn Ottosson matrix.
@@ -2166,10 +2628,19 @@ fn eval_contextual_calc(expr: &str, context: &LengthContext) -> Option<f32> {
current.push(character);
}
'+' | '-' if depth == 0 => {
if !current.trim().is_empty() {
terms.push((sign, std::mem::take(&mut current)));
let follows_product_operator = current
.chars()
.rev()
.find(|character| !character.is_whitespace())
.is_some_and(|character| matches!(character, '*' | '/'));
if follows_product_operator {
current.push(character);
} else {
if !current.trim().is_empty() {
terms.push((sign, std::mem::take(&mut current)));
}
sign = if character == '-' { -1.0 } else { 1.0 };
}
sign = if character == '-' { -1.0 } else { 1.0 };
}
_ => current.push(character),
}
@@ -2352,10 +2823,19 @@ fn eval_calc(expr: &str) -> Option<f32> {
'(' => { depth += 1; cur.push(c); }
')' => { depth -= 1; cur.push(c); }
'+' | '-' if depth == 0 => {
if !cur.trim().is_empty() {
terms.push((sign, std::mem::take(&mut cur)));
let follows_product_operator = cur
.chars()
.rev()
.find(|character| !character.is_whitespace())
.is_some_and(|character| matches!(character, '*' | '/'));
if follows_product_operator {
cur.push(c);
} else {
if !cur.trim().is_empty() {
terms.push((sign, std::mem::take(&mut cur)));
}
sign = if c == '-' { -1.0 } else { 1.0 };
}
sign = if c == '-' { -1.0 } else { 1.0 };
}
_ => cur.push(c),
}
@@ -2912,7 +3392,10 @@ fn token(value: &str) -> Option<&str> {
/// clockwise); `to <side>` keywords map to their angle. Color stops keep their
/// optional 0..1 position. Returns None if it is not a linear-gradient or has
/// no parseable colors. Radial/conic gradients are not handled (None).
fn parse_linear_gradient(value: &str) -> Option<(f32, Vec<([u8; 4], Option<f32>)>)> {
fn parse_linear_gradient(
value: &str,
dark_scheme: bool,
) -> Option<(f32, Vec<([u8; 4], Option<f32>)>)> {
let v = value.trim();
let lower = v.to_ascii_lowercase();
let start = lower.find("linear-gradient(")?;
@@ -2997,7 +3480,7 @@ fn parse_linear_gradient(value: &str) -> Option<(f32, Vec<([u8; 4], Option<f32>)
} else {
(t, None)
};
if let Some(c) = parse_color(color_str) {
if let Some(c) = parse_color_for_scheme(color_str, dark_scheme) {
stops.push((c, pos));
}
}
@@ -3011,6 +3494,7 @@ fn parse_linear_gradient(value: &str) -> Option<(f32, Vec<([u8; 4], Option<f32>)
fn parse_radial_gradient(
value: &str,
dark_scheme: bool,
) -> Option<((f32, f32), Vec<([u8; 4], Option<f32>)>)> {
let lower = value.to_ascii_lowercase();
let start = lower.find("radial-gradient(")?;
@@ -3037,13 +3521,13 @@ fn parse_radial_gradient(
center.1 = y;
}
stop_start = 1;
} else if parse_color(parts[0].trim()).is_none() {
} else if parse_color_for_scheme(parts[0].trim(), dark_scheme).is_none() {
stop_start = 1;
}
let mut stops = Vec::new();
for part in &parts[stop_start..] {
let (color, position) = split_color_stop(part.trim());
if let Some(color) = parse_color(color) {
if let Some(color) = parse_color_for_scheme(color, dark_scheme) {
stops.push((color, position));
}
}
@@ -3055,6 +3539,7 @@ fn parse_radial_gradient(
/// is retained as box-relative fractions for paint-time resolution.
fn parse_conic_gradient(
value: &str,
dark_scheme: bool,
) -> Option<(f32, (f32, f32), Vec<([u8; 4], Option<f32>)>)> {
let v = value.trim();
let lower = v.to_ascii_lowercase();
@@ -3106,7 +3591,7 @@ fn parse_conic_gradient(
continue;
}
let (color, position) = split_color_stop(part);
if let Some(color) = parse_color(color) {
if let Some(color) = parse_color_for_scheme(color, dark_scheme) {
stops.push((color, position));
}
}
@@ -3382,7 +3867,11 @@ fn parse_background_position(value: &str) -> (f32, f32) {
/// keyword and the color may each lead or trail the lengths; comma-separated
/// multiples are accepted but only the first layer is stored. `current_color`
/// supplies the default when the color is omitted (CSS `currentColor`).
fn parse_box_shadow(value: &str, current_color: Option<[u8; 4]>) -> Option<crate::BoxShadow> {
fn parse_box_shadow(
value: &str,
current_color: Option<[u8; 4]>,
dark_scheme: bool,
) -> Option<crate::BoxShadow> {
let v = value.trim();
if v.is_empty() || v.eq_ignore_ascii_case("none") {
return None;
@@ -3411,7 +3900,7 @@ fn parse_box_shadow(value: &str, current_color: Option<[u8; 4]>) -> Option<crate
continue;
}
}
if let Some(c) = parse_color(t) {
if let Some(c) = parse_color_for_scheme(t, dark_scheme) {
color = Some(c);
}
}
@@ -3835,6 +4324,17 @@ mod tests {
),
Some(122.0)
);
assert_eq!(
resolve_contextual_length(
"calc(35rem*-1/4)",
context.0,
context.1,
context.2,
context.3,
context.4,
),
Some(-140.0)
);
assert_eq!(
resolve_contextual_length(
"calc(round(247px * 1, 10px))",
@@ -3896,6 +4396,58 @@ mod tests {
assert!(l.background_clip_text);
}
#[test]
fn light_dark_uses_light_scheme_and_validates_both_nested_branches() {
assert_eq!(
parse_color("light-dark(rgb(16, 32, 48), hsl(0 100% 50%))"),
Some([16, 32, 48, 255])
);
assert_eq!(
parse_color(
"LIGHT-DARK(color-mix(in srgb, #204060 75%, transparent), \
light-dark(white, black))"
),
Some([32, 64, 96, 191]),
"nested commas must stay inside their color function"
);
let style = compute_style(
"div",
Some(
"color:light-dark(#123456,#ffffff);\
background-color:light-dark(rgb(1, 2, 3),rgb(4, 5, 6));\
border-color:light-dark(red,blue)",
),
);
assert_eq!(style.color, Some([0x12, 0x34, 0x56, 255]));
assert_eq!(style.background_color, Some([1, 2, 3, 255]));
assert_eq!(style.border_color, Some([255, 0, 0, 255]));
assert!(supports_declaration(
"color",
"light-dark(rgb(1, 2, 3), color-mix(in srgb, white 50%, black))"
));
for malformed in [
"light-dark(red)",
"light-dark(red, blue, green)",
"light-dark(red,)",
"light-dark(,blue)",
"light-dark(red, rgb(1, 2, 3)",
"light-dark(red garbage, blue)",
"light-dark(red, definitely-not-a-color)",
"light-dark(red, blue) trailing",
] {
assert_eq!(
parse_color(malformed),
None,
"malformed light-dark() must invalidate the declaration: {malformed}"
);
assert!(
!supports_declaration("color", malformed),
"@supports must reject malformed light-dark(): {malformed}"
);
}
}
#[test]
fn background_shorthand_resets_omitted_layers() {
let s = compute_style(
@@ -3998,6 +4550,9 @@ mod tests {
// The exact shape MediaWiki uses to offset a TOC toggle button into
// the left margin: a negative product divided by a constant.
assert_eq!(resolve_length("calc(-1 * 22px / 2)"), Some(-11.0));
// Minifiers commonly remove every optional space. A sign immediately
// following `*` or `/` is unary and belongs to that factor.
assert_eq!(resolve_length("calc(35px*-1/4)"), Some(-8.75));
}
#[test]