diff --git a/crates/op-html/src/css/cascade.rs b/crates/op-html/src/css/cascade.rs new file mode 100644 index 000000000..dd6b59b63 --- /dev/null +++ b/crates/op-html/src/css/cascade.rs @@ -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, + pub order: usize, +} + +#[derive(Clone, Debug)] +pub struct ComputedStyle { + pub props: BTreeMap, + 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, Vec) { + 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 { + 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, + rules: &[((u32, u32, u32), usize, &StyleRule)], + important: bool, +) { + for (_, _, rule) in rules { + apply_declarations(props, &rule.declarations, important); + } +} + +fn apply_declarations( + props: &mut BTreeMap, + 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 { + 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")); + } +} diff --git a/crates/op-html/src/css/declarations.rs b/crates/op-html/src/css/declarations.rs new file mode 100644 index 000000000..3701dfd72 --- /dev/null +++ b/crates/op-html/src/css/declarations.rs @@ -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 { + 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::().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, 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, 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, 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::().is_ok()) + }) +} + +fn expand_background(out: &mut Vec, 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, 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::() + .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::().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")); + } +} diff --git a/crates/op-html/src/css/mod.rs b/crates/op-html/src/css/mod.rs new file mode 100644 index 000000000..c714953fe --- /dev/null +++ b/crates/op-html/src/css/mod.rs @@ -0,0 +1,3 @@ +pub mod cascade; +pub mod declarations; +pub mod selectors; diff --git a/crates/op-html/src/css/selectors.rs b/crates/op-html/src/css/selectors.rs new file mode 100644 index 000000000..f0c660241 --- /dev/null +++ b/crates/op-html/src/css/selectors.rs @@ -0,0 +1,182 @@ +use crate::dom::DomElement; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CompoundSelector { + pub tag: Option, + pub id: Option, + pub classes: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Selector { + pub compounds: Vec, +} + +pub fn parse_selector_list(input: &str) -> Vec { + input + .split(',') + .filter_map(|candidate| { + let candidate = candidate.trim(); + if candidate.is_empty() + || candidate + .chars() + .any(|ch| matches!(ch, '>' | '+' | '~' | '[' | ':' | '*')) + { + return None; + } + let compounds: Option> = + candidate.split_whitespace().map(parse_compound).collect(); + let compounds = compounds?; + (!compounds.is_empty()).then_some(Selector { compounds }) + }) + .collect() +} + +fn parse_compound(input: &str) -> Option { + 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)); + } +}