feat(html): declaration parsing, selector subset and cascade with ua defaults
This commit is contained in:
parent
c9e06817dc
commit
623e503617
317
crates/op-html/src/css/cascade.rs
Normal file
317
crates/op-html/src/css/cascade.rs
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use crate::css::declarations::{parse_declarations, Declaration};
|
||||
use crate::css::selectors::{matches, parse_selector_list, specificity, Selector};
|
||||
use crate::dom::DomElement;
|
||||
use crate::length::{parse_length, CssLength, LengthCtx};
|
||||
|
||||
pub const UA_STYLESHEET: &str = "\
|
||||
body{font-size:16px;color:#111111}\
|
||||
h1{font-size:32px;font-weight:700;margin:21px 0}\
|
||||
h2{font-size:24px;font-weight:700;margin:20px 0}\
|
||||
h3{font-size:19px;font-weight:700;margin:18px 0}\
|
||||
h4{font-size:16px;font-weight:700;margin:21px 0}\
|
||||
h5{font-size:13px;font-weight:700;margin:22px 0}\
|
||||
h6{font-size:11px;font-weight:700;margin:24px 0}\
|
||||
p{margin:16px 0}\
|
||||
ul,ol{margin:16px 0;padding:0 0 0 40px}\
|
||||
b,strong{font-weight:700}\
|
||||
i,em{font-style:italic}\
|
||||
u{text-decoration:underline}\
|
||||
s,del,strike{text-decoration:line-through}\
|
||||
a{color:#0066cc;text-decoration:underline}\
|
||||
code,pre{font-family:monospace}\
|
||||
hr{margin:8px 0}";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct StyleRule {
|
||||
pub selector: Selector,
|
||||
pub declarations: Vec<Declaration>,
|
||||
pub order: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ComputedStyle {
|
||||
pub props: BTreeMap<String, String>,
|
||||
pub font_size: f64,
|
||||
}
|
||||
|
||||
impl ComputedStyle {
|
||||
pub fn get(&self, name: &str) -> Option<&str> {
|
||||
self.props.get(name).map(String::as_str)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_stylesheet(css: &str, first_order: usize) -> (Vec<StyleRule>, Vec<String>) {
|
||||
let css = strip_comments(css);
|
||||
let mut rules = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
let mut warned_at_rules = BTreeSet::new();
|
||||
let mut index = 0;
|
||||
let mut order = first_order;
|
||||
while index < css.len() {
|
||||
index = skip_whitespace(&css, index);
|
||||
if index >= css.len() {
|
||||
break;
|
||||
}
|
||||
if css.as_bytes()[index] == b'@' {
|
||||
let name_end = css[index + 1..]
|
||||
.find(|ch: char| ch.is_whitespace() || ch == '{' || ch == ';')
|
||||
.map_or(css.len(), |offset| index + 1 + offset);
|
||||
let name = css[index + 1..name_end].to_ascii_lowercase();
|
||||
if warned_at_rules.insert(name.clone()) {
|
||||
warnings.push(at_rule_warning(&name));
|
||||
}
|
||||
let next_brace = css[index..].find('{').map(|offset| index + offset);
|
||||
let next_semicolon = css[index..].find(';').map(|offset| index + offset);
|
||||
index = match (next_brace, next_semicolon) {
|
||||
(Some(brace), Some(semicolon)) if semicolon < brace => semicolon + 1,
|
||||
(Some(brace), _) => matching_brace(&css, brace).map_or(css.len(), |end| end + 1),
|
||||
(_, Some(semicolon)) => semicolon + 1,
|
||||
_ => css.len(),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
let Some(open_offset) = css[index..].find('{') else {
|
||||
break;
|
||||
};
|
||||
let open = index + open_offset;
|
||||
let Some(close) = matching_brace(&css, open) else {
|
||||
break;
|
||||
};
|
||||
let selectors = parse_selector_list(css[index..open].trim());
|
||||
let declarations = parse_declarations(&css[open + 1..close]);
|
||||
for selector in selectors {
|
||||
rules.push(StyleRule {
|
||||
selector,
|
||||
declarations: declarations.clone(),
|
||||
order,
|
||||
});
|
||||
order = order.saturating_add(1);
|
||||
}
|
||||
index = close + 1;
|
||||
}
|
||||
(rules, warnings)
|
||||
}
|
||||
|
||||
fn strip_comments(css: &str) -> String {
|
||||
let mut stripped = String::with_capacity(css.len());
|
||||
let mut rest = css;
|
||||
while let Some(start) = rest.find("/*") {
|
||||
stripped.push_str(&rest[..start]);
|
||||
let after_start = &rest[start + 2..];
|
||||
if let Some(end) = after_start.find("*/") {
|
||||
rest = &after_start[end + 2..];
|
||||
} else {
|
||||
rest = "";
|
||||
}
|
||||
}
|
||||
stripped.push_str(rest);
|
||||
stripped
|
||||
}
|
||||
|
||||
fn skip_whitespace(css: &str, mut index: usize) -> usize {
|
||||
while index < css.len() && css.as_bytes()[index].is_ascii_whitespace() {
|
||||
index += 1;
|
||||
}
|
||||
index
|
||||
}
|
||||
|
||||
fn matching_brace(css: &str, open: usize) -> Option<usize> {
|
||||
let mut depth = 0u32;
|
||||
let mut quote = None;
|
||||
for (offset, ch) in css[open..].char_indices() {
|
||||
if let Some(active) = quote {
|
||||
if ch == active {
|
||||
quote = None;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match ch {
|
||||
'\'' | '"' => quote = Some(ch),
|
||||
'{' => depth += 1,
|
||||
'}' => {
|
||||
depth = depth.saturating_sub(1);
|
||||
if depth == 0 {
|
||||
return Some(open + offset);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn at_rule_warning(name: &str) -> String {
|
||||
if name == "media" {
|
||||
"@media rules ignored (import viewport applies)".to_string()
|
||||
} else {
|
||||
format!("@{name} rules ignored")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compute_style(
|
||||
path: &[&DomElement],
|
||||
rules: &[StyleRule],
|
||||
parent: Option<&ComputedStyle>,
|
||||
root_font_size: f64,
|
||||
) -> ComputedStyle {
|
||||
let mut matching_rules: Vec<_> = rules
|
||||
.iter()
|
||||
.filter(|rule| matches(&rule.selector, path))
|
||||
.map(|rule| (specificity(&rule.selector), rule.order, rule))
|
||||
.collect();
|
||||
matching_rules.sort_by_key(|(specificity, order, _)| (*specificity, *order));
|
||||
|
||||
let inline = path
|
||||
.last()
|
||||
.and_then(|element| element.attr("style"))
|
||||
.map(parse_declarations)
|
||||
.unwrap_or_default();
|
||||
let mut props = BTreeMap::new();
|
||||
apply_rule_declarations(&mut props, &matching_rules, false);
|
||||
apply_declarations(&mut props, &inline, false);
|
||||
apply_rule_declarations(&mut props, &matching_rules, true);
|
||||
apply_declarations(&mut props, &inline, true);
|
||||
|
||||
let declared_font_size = props.get("font-size").cloned();
|
||||
if let Some(parent) = parent {
|
||||
for &property in INHERITED_PROPERTIES {
|
||||
if !props.contains_key(property) {
|
||||
if let Some(value) = parent.props.get(property) {
|
||||
props.insert(property.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let inherited_size = parent.map_or(root_font_size, |style| style.font_size);
|
||||
let font_size = declared_font_size
|
||||
.as_deref()
|
||||
.and_then(|value| resolve_font_size(value, inherited_size, root_font_size))
|
||||
.unwrap_or(inherited_size);
|
||||
ComputedStyle { props, font_size }
|
||||
}
|
||||
|
||||
const INHERITED_PROPERTIES: &[&str] = &[
|
||||
"color",
|
||||
"font-family",
|
||||
"font-size",
|
||||
"font-weight",
|
||||
"font-style",
|
||||
"line-height",
|
||||
"letter-spacing",
|
||||
"text-align",
|
||||
];
|
||||
|
||||
fn apply_rule_declarations(
|
||||
props: &mut BTreeMap<String, String>,
|
||||
rules: &[((u32, u32, u32), usize, &StyleRule)],
|
||||
important: bool,
|
||||
) {
|
||||
for (_, _, rule) in rules {
|
||||
apply_declarations(props, &rule.declarations, important);
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_declarations(
|
||||
props: &mut BTreeMap<String, String>,
|
||||
declarations: &[Declaration],
|
||||
important: bool,
|
||||
) {
|
||||
for declaration in declarations
|
||||
.iter()
|
||||
.filter(|declaration| declaration.important == important)
|
||||
{
|
||||
props.insert(declaration.name.clone(), declaration.value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_font_size(value: &str, parent_size: f64, root_size: f64) -> Option<f64> {
|
||||
let context = LengthCtx {
|
||||
font_size: parent_size,
|
||||
root_font_size: root_size,
|
||||
viewport_w: 0.0,
|
||||
viewport_h: 0.0,
|
||||
};
|
||||
match parse_length(value, &context)? {
|
||||
CssLength::Px(value) => Some(value),
|
||||
CssLength::Percent(percent) => Some(parent_size * percent / 100.0),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::dom::DomElement;
|
||||
|
||||
fn el(tag: &str, class: &str, style: &str) -> DomElement {
|
||||
let mut attrs = Vec::new();
|
||||
if !class.is_empty() {
|
||||
attrs.push(("class".into(), class.into()));
|
||||
}
|
||||
if !style.is_empty() {
|
||||
attrs.push(("style".into(), style.into()));
|
||||
}
|
||||
DomElement {
|
||||
tag: tag.into(),
|
||||
attrs,
|
||||
children: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn specificity_and_order_win() {
|
||||
let (rules, _) = parse_stylesheet(
|
||||
"p { color: #111111 } .hot { color: #ff0000 } p { margin-top: 4px }",
|
||||
100,
|
||||
);
|
||||
let paragraph = el("p", "hot", "");
|
||||
let computed = compute_style(&[¶graph], &rules, None, 16.0);
|
||||
assert_eq!(computed.get("color"), Some("#ff0000"));
|
||||
assert_eq!(computed.get("margin-top"), Some("4px"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_beats_rules_but_important_beats_inline() {
|
||||
let (rules, _) = parse_stylesheet(".a { color: #00ff00 !important }", 100);
|
||||
let div = el("div", "a", "color: #0000ff");
|
||||
let computed = compute_style(&[&div], &rules, None, 16.0);
|
||||
assert_eq!(computed.get("color"), Some("#00ff00"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inheritance_and_font_size_units() {
|
||||
let (rules, _) = parse_stylesheet("div { color: #333333; font-size: 20px }", 100);
|
||||
let parent_element = el("div", "", "");
|
||||
let parent = compute_style(&[&parent_element], &rules, None, 16.0);
|
||||
assert_eq!(parent.font_size, 20.0);
|
||||
let child_element = el("span", "", "font-size: 1.5em");
|
||||
let child = compute_style(
|
||||
&[&parent_element, &child_element],
|
||||
&rules,
|
||||
Some(&parent),
|
||||
16.0,
|
||||
);
|
||||
assert_eq!(child.get("color"), Some("#333333"));
|
||||
assert_eq!(child.font_size, 30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn at_rules_skipped_with_warning() {
|
||||
let (rules, warnings) = parse_stylesheet(
|
||||
"@media (max-width:600px){ p{color:red} } p{color:#222222}",
|
||||
0,
|
||||
);
|
||||
assert_eq!(rules.len(), 1);
|
||||
assert_eq!(warnings.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ua_defaults_apply() {
|
||||
let (rules, _) = parse_stylesheet(UA_STYLESHEET, 0);
|
||||
let heading = el("h1", "", "");
|
||||
let computed = compute_style(&[&heading], &rules, None, 16.0);
|
||||
assert_eq!(computed.font_size, 32.0);
|
||||
assert_eq!(computed.get("font-weight"), Some("700"));
|
||||
}
|
||||
}
|
||||
317
crates/op-html/src/css/declarations.rs
Normal file
317
crates/op-html/src/css/declarations.rs
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
use crate::color::parse_css_color;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Declaration {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
pub important: bool,
|
||||
}
|
||||
|
||||
pub fn parse_declarations(block: &str) -> Vec<Declaration> {
|
||||
let mut declarations = Vec::new();
|
||||
for statement in split_top_level(block, ';') {
|
||||
let Some((name, raw_value)) = statement.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
let name = name.trim().to_ascii_lowercase();
|
||||
let (value, important) = strip_important(raw_value);
|
||||
if name.is_empty() || value.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match name.as_str() {
|
||||
"margin" | "padding" => expand_box(&mut declarations, &name, value, important),
|
||||
"border" => expand_border(&mut declarations, None, value, important),
|
||||
"border-top" | "border-right" | "border-bottom" | "border-left" => expand_border(
|
||||
&mut declarations,
|
||||
name.strip_prefix("border-"),
|
||||
value,
|
||||
important,
|
||||
),
|
||||
"background" => expand_background(&mut declarations, value, important),
|
||||
"font" => expand_font(&mut declarations, value, important),
|
||||
"gap" => {
|
||||
if let Some(first) = value.split_whitespace().next() {
|
||||
push(&mut declarations, "gap", first, important);
|
||||
}
|
||||
}
|
||||
"flex" => {
|
||||
if let Some(first) = value.split_whitespace().next() {
|
||||
if first.parse::<f64>().is_ok() {
|
||||
push(&mut declarations, "flex-grow", first, important);
|
||||
}
|
||||
}
|
||||
}
|
||||
"text-decoration" => {
|
||||
if value.split_whitespace().any(|part| part == "underline") {
|
||||
push(
|
||||
&mut declarations,
|
||||
"text-decoration-line",
|
||||
"underline",
|
||||
important,
|
||||
);
|
||||
}
|
||||
if value.split_whitespace().any(|part| part == "line-through") {
|
||||
push(
|
||||
&mut declarations,
|
||||
"text-decoration-line",
|
||||
"line-through",
|
||||
important,
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => push(&mut declarations, &name, value, important),
|
||||
}
|
||||
}
|
||||
declarations
|
||||
}
|
||||
|
||||
fn split_top_level(input: &str, delimiter: char) -> Vec<&str> {
|
||||
let mut parts = Vec::new();
|
||||
let mut start = 0;
|
||||
let mut depth = 0u32;
|
||||
let mut quote = None;
|
||||
for (index, ch) in input.char_indices() {
|
||||
if let Some(active) = quote {
|
||||
if ch == active {
|
||||
quote = None;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match ch {
|
||||
'\'' | '"' => quote = Some(ch),
|
||||
'(' => depth += 1,
|
||||
')' => depth = depth.saturating_sub(1),
|
||||
_ if ch == delimiter && depth == 0 => {
|
||||
parts.push(input[start..index].trim());
|
||||
start = index + ch.len_utf8();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
parts.push(input[start..].trim());
|
||||
parts
|
||||
}
|
||||
|
||||
fn strip_important(value: &str) -> (&str, bool) {
|
||||
let trimmed = value.trim();
|
||||
let lower = trimmed.to_ascii_lowercase();
|
||||
if lower.ends_with("!important") {
|
||||
let cutoff = trimmed.len() - "!important".len();
|
||||
(trimmed[..cutoff].trim_end(), true)
|
||||
} else {
|
||||
(trimmed, false)
|
||||
}
|
||||
}
|
||||
|
||||
fn push(out: &mut Vec<Declaration>, name: &str, value: &str, important: bool) {
|
||||
out.push(Declaration {
|
||||
name: name.to_string(),
|
||||
value: value.trim().to_string(),
|
||||
important,
|
||||
});
|
||||
}
|
||||
|
||||
fn expand_box(out: &mut Vec<Declaration>, name: &str, value: &str, important: bool) {
|
||||
let values: Vec<_> = value.split_whitespace().collect();
|
||||
let sides = match values.as_slice() {
|
||||
[all] => [*all, *all, *all, *all],
|
||||
[vertical, horizontal] => [*vertical, *horizontal, *vertical, *horizontal],
|
||||
[top, horizontal, bottom] => [*top, *horizontal, *bottom, *horizontal],
|
||||
[top, right, bottom, left] => [*top, *right, *bottom, *left],
|
||||
_ => return,
|
||||
};
|
||||
for (side, value) in ["top", "right", "bottom", "left"].into_iter().zip(sides) {
|
||||
push(out, &format!("{name}-{side}"), value, important);
|
||||
}
|
||||
}
|
||||
|
||||
fn expand_border(out: &mut Vec<Declaration>, side: Option<&str>, value: &str, important: bool) {
|
||||
let mut width = None;
|
||||
let mut style = None;
|
||||
let mut color = None;
|
||||
for token in value.split_whitespace() {
|
||||
if parse_css_color(token).is_some() {
|
||||
color = Some(token);
|
||||
} else if is_border_width(token) {
|
||||
width = Some(token);
|
||||
} else if matches!(
|
||||
token,
|
||||
"none"
|
||||
| "hidden"
|
||||
| "dotted"
|
||||
| "dashed"
|
||||
| "solid"
|
||||
| "double"
|
||||
| "groove"
|
||||
| "ridge"
|
||||
| "inset"
|
||||
| "outset"
|
||||
) {
|
||||
style = Some(token);
|
||||
}
|
||||
}
|
||||
let prefix = side.map_or_else(|| "border".to_string(), |side| format!("border-{side}"));
|
||||
if let Some(width) = width {
|
||||
push(out, &format!("{prefix}-width"), width, important);
|
||||
}
|
||||
if side.is_none() {
|
||||
if let Some(style) = style {
|
||||
push(out, "border-style", style, important);
|
||||
}
|
||||
}
|
||||
if let Some(color) = color {
|
||||
push(out, &format!("{prefix}-color"), color, important);
|
||||
}
|
||||
}
|
||||
|
||||
fn is_border_width(token: &str) -> bool {
|
||||
matches!(token, "thin" | "medium" | "thick" | "0")
|
||||
|| ["px", "em", "rem", "pt"].iter().any(|unit| {
|
||||
token
|
||||
.strip_suffix(unit)
|
||||
.is_some_and(|n| n.parse::<f64>().is_ok())
|
||||
})
|
||||
}
|
||||
|
||||
fn expand_background(out: &mut Vec<Declaration>, value: &str, important: bool) {
|
||||
if value.contains("-gradient(") {
|
||||
push(out, "background-image", value, important);
|
||||
return;
|
||||
}
|
||||
if let Some(image) = extract_function(value, "url(") {
|
||||
push(out, "background-image", image, important);
|
||||
}
|
||||
if parse_css_color(value).is_some() {
|
||||
push(out, "background-color", value, important);
|
||||
return;
|
||||
}
|
||||
if let Some(color) = value
|
||||
.split_whitespace()
|
||||
.find(|token| parse_css_color(token).is_some())
|
||||
{
|
||||
push(out, "background-color", color, important);
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_function<'a>(value: &'a str, prefix: &str) -> Option<&'a str> {
|
||||
let start = value.find(prefix)?;
|
||||
let mut depth = 0u32;
|
||||
for (offset, ch) in value[start..].char_indices() {
|
||||
match ch {
|
||||
'(' => depth += 1,
|
||||
')' => {
|
||||
depth = depth.saturating_sub(1);
|
||||
if depth == 0 {
|
||||
return Some(&value[start..start + offset + 1]);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn expand_font(out: &mut Vec<Declaration>, value: &str, important: bool) {
|
||||
let tokens: Vec<_> = value.split_whitespace().collect();
|
||||
let Some(size_index) = tokens.iter().position(|token| is_font_size_token(token)) else {
|
||||
return;
|
||||
};
|
||||
for token in &tokens[..size_index] {
|
||||
if *token == "italic" || *token == "oblique" {
|
||||
push(out, "font-style", token, important);
|
||||
} else if *token == "bold"
|
||||
|| token
|
||||
.parse::<u16>()
|
||||
.is_ok_and(|weight| (100..=900).contains(&weight))
|
||||
{
|
||||
push(out, "font-weight", token, important);
|
||||
}
|
||||
}
|
||||
let (size, line_height) = tokens[size_index]
|
||||
.split_once('/')
|
||||
.map_or((tokens[size_index], None), |(size, height)| {
|
||||
(size, Some(height))
|
||||
});
|
||||
push(out, "font-size", size, important);
|
||||
if let Some(line_height) = line_height {
|
||||
push(out, "line-height", line_height, important);
|
||||
}
|
||||
if size_index + 1 < tokens.len() {
|
||||
push(
|
||||
out,
|
||||
"font-family",
|
||||
&tokens[size_index + 1..].join(" "),
|
||||
important,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn is_font_size_token(token: &str) -> bool {
|
||||
let size = token.split('/').next().unwrap_or(token);
|
||||
["px", "em", "rem", "pt", "%"].iter().any(|unit| {
|
||||
size.strip_suffix(unit)
|
||||
.is_some_and(|n| n.parse::<f64>().is_ok())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn get<'a>(declarations: &'a [Declaration], name: &str) -> Option<&'a str> {
|
||||
declarations
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|declaration| declaration.name == name)
|
||||
.map(|declaration| declaration.value.as_str())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn margin_shorthand_expands() {
|
||||
let declarations = parse_declarations("margin: 10px 20px");
|
||||
assert_eq!(get(&declarations, "margin-top"), Some("10px"));
|
||||
assert_eq!(get(&declarations, "margin-right"), Some("20px"));
|
||||
assert_eq!(get(&declarations, "margin-bottom"), Some("10px"));
|
||||
assert_eq!(get(&declarations, "margin-left"), Some("20px"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn border_and_background() {
|
||||
let declarations =
|
||||
parse_declarations("border: 1px solid #000; background: #fff url(x.png)");
|
||||
assert_eq!(get(&declarations, "border-width"), Some("1px"));
|
||||
assert_eq!(get(&declarations, "border-color"), Some("#000"));
|
||||
assert_eq!(get(&declarations, "background-image"), Some("url(x.png)"));
|
||||
assert_eq!(get(&declarations, "background-color"), Some("#fff"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn important_flag_and_case() {
|
||||
let declarations = parse_declarations("COLOR: red !important");
|
||||
assert_eq!(declarations[0].name, "color");
|
||||
assert_eq!(declarations[0].value, "red");
|
||||
assert!(declarations[0].important);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_goes_to_background_image() {
|
||||
let declarations = parse_declarations("background: linear-gradient(90deg, #000, #fff)");
|
||||
assert_eq!(
|
||||
get(&declarations, "background-image"),
|
||||
Some("linear-gradient(90deg, #000, #fff)")
|
||||
);
|
||||
assert_eq!(get(&declarations, "background-color"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flex_and_font() {
|
||||
let declarations =
|
||||
parse_declarations("flex: 1; font: italic 700 18px/1.4 Inter, sans-serif");
|
||||
assert_eq!(get(&declarations, "flex-grow"), Some("1"));
|
||||
assert_eq!(get(&declarations, "font-style"), Some("italic"));
|
||||
assert_eq!(get(&declarations, "font-weight"), Some("700"));
|
||||
assert_eq!(get(&declarations, "font-size"), Some("18px"));
|
||||
assert_eq!(get(&declarations, "line-height"), Some("1.4"));
|
||||
assert_eq!(get(&declarations, "font-family"), Some("Inter, sans-serif"));
|
||||
}
|
||||
}
|
||||
3
crates/op-html/src/css/mod.rs
Normal file
3
crates/op-html/src/css/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod cascade;
|
||||
pub mod declarations;
|
||||
pub mod selectors;
|
||||
182
crates/op-html/src/css/selectors.rs
Normal file
182
crates/op-html/src/css/selectors.rs
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
use crate::dom::DomElement;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CompoundSelector {
|
||||
pub tag: Option<String>,
|
||||
pub id: Option<String>,
|
||||
pub classes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Selector {
|
||||
pub compounds: Vec<CompoundSelector>,
|
||||
}
|
||||
|
||||
pub fn parse_selector_list(input: &str) -> Vec<Selector> {
|
||||
input
|
||||
.split(',')
|
||||
.filter_map(|candidate| {
|
||||
let candidate = candidate.trim();
|
||||
if candidate.is_empty()
|
||||
|| candidate
|
||||
.chars()
|
||||
.any(|ch| matches!(ch, '>' | '+' | '~' | '[' | ':' | '*'))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let compounds: Option<Vec<_>> =
|
||||
candidate.split_whitespace().map(parse_compound).collect();
|
||||
let compounds = compounds?;
|
||||
(!compounds.is_empty()).then_some(Selector { compounds })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_compound(input: &str) -> Option<CompoundSelector> {
|
||||
let bytes = input.as_bytes();
|
||||
let mut index = 0;
|
||||
let mut tag = None;
|
||||
let mut id = None;
|
||||
let mut classes = Vec::new();
|
||||
if !matches!(bytes.first(), Some(b'.' | b'#')) {
|
||||
let end = input.find(['.', '#']).unwrap_or(input.len());
|
||||
if end == 0 {
|
||||
return None;
|
||||
}
|
||||
tag = Some(input[..end].to_ascii_lowercase());
|
||||
index = end;
|
||||
}
|
||||
while index < input.len() {
|
||||
let marker = bytes[index];
|
||||
if marker != b'.' && marker != b'#' {
|
||||
return None;
|
||||
}
|
||||
let start = index + 1;
|
||||
let relative_end = input[start..]
|
||||
.find(['.', '#'])
|
||||
.unwrap_or(input.len() - start);
|
||||
let end = start + relative_end;
|
||||
if start == end {
|
||||
return None;
|
||||
}
|
||||
let token = input[start..end].to_string();
|
||||
if marker == b'#' {
|
||||
if id.replace(token).is_some() {
|
||||
return None;
|
||||
}
|
||||
} else {
|
||||
classes.push(token);
|
||||
}
|
||||
index = end;
|
||||
}
|
||||
Some(CompoundSelector { tag, id, classes })
|
||||
}
|
||||
|
||||
pub fn specificity(selector: &Selector) -> (u32, u32, u32) {
|
||||
selector
|
||||
.compounds
|
||||
.iter()
|
||||
.fold((0, 0, 0), |(ids, classes, tags), compound| {
|
||||
(
|
||||
ids + u32::from(compound.id.is_some()),
|
||||
classes + compound.classes.len() as u32,
|
||||
tags + u32::from(compound.tag.is_some()),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn matches(selector: &Selector, path: &[&DomElement]) -> bool {
|
||||
let Some((target, ancestors)) = path.split_last() else {
|
||||
return false;
|
||||
};
|
||||
let Some((target_selector, earlier_selectors)) = selector.compounds.split_last() else {
|
||||
return false;
|
||||
};
|
||||
if !compound_matches(target_selector, target) {
|
||||
return false;
|
||||
}
|
||||
let mut ancestor_end = ancestors.len();
|
||||
for compound in earlier_selectors.iter().rev() {
|
||||
let Some(index) = ancestors[..ancestor_end]
|
||||
.iter()
|
||||
.rposition(|element| compound_matches(compound, element))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
ancestor_end = index;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn compound_matches(selector: &CompoundSelector, element: &DomElement) -> bool {
|
||||
if selector
|
||||
.tag
|
||||
.as_deref()
|
||||
.is_some_and(|tag| tag != element.tag)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if selector
|
||||
.id
|
||||
.as_deref()
|
||||
.is_some_and(|id| Some(id) != element.id())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let element_classes = element.classes();
|
||||
selector
|
||||
.classes
|
||||
.iter()
|
||||
.all(|class| element_classes.contains(&class.as_str()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::dom::DomElement;
|
||||
|
||||
fn el(tag: &str, class: &str, id: &str) -> DomElement {
|
||||
let mut attrs = Vec::new();
|
||||
if !class.is_empty() {
|
||||
attrs.push(("class".into(), class.into()));
|
||||
}
|
||||
if !id.is_empty() {
|
||||
attrs.push(("id".into(), id.into()));
|
||||
}
|
||||
DomElement {
|
||||
tag: tag.into(),
|
||||
attrs,
|
||||
children: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_and_scores() {
|
||||
let selectors = parse_selector_list("div.card, #hero .title, p");
|
||||
assert_eq!(selectors.len(), 3);
|
||||
assert_eq!(specificity(&selectors[0]), (0, 1, 1));
|
||||
assert_eq!(specificity(&selectors[1]), (1, 1, 0));
|
||||
assert_eq!(specificity(&selectors[2]), (0, 0, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_selectors_are_dropped() {
|
||||
let selectors = parse_selector_list("a:hover, div > p, .ok");
|
||||
assert_eq!(selectors.len(), 1);
|
||||
assert_eq!(selectors[0].compounds[0].classes, vec!["ok".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descendant_matching() {
|
||||
let hero = el("section", "", "hero");
|
||||
let mid = el("div", "card", "");
|
||||
let title = el("h2", "title", "");
|
||||
let path: Vec<&DomElement> = vec![&hero, &mid, &title];
|
||||
let selector = &parse_selector_list("#hero .title")[0];
|
||||
assert!(matches(selector, &path));
|
||||
let selector = &parse_selector_list("#hero .card .title")[0];
|
||||
assert!(matches(selector, &path));
|
||||
let selector = &parse_selector_list("#other .title")[0];
|
||||
assert!(!matches(selector, &path));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue