H
horizon-browser
↓ 0 downloadsHorizon: a real box-model HTML/CSS layout engine with a Canvas 2D surface, no_std + alloc, zero dependencies -- LingOS's next-generation browser engine, sibling to bring-browser
Keywords browser, html, css, layout, canvas, no_std, lingos
lingfu install horizon-browserCode / Files (9)
src/css.rs
//! CSS subset: inline `style=""` plus `<style>` blocks, matched with tag,
//! `.class`, and `#id` selectors (no combinators, no compound selectors --
//! same staged scope `bring-browser` documents for its own CSS engine).
//! Cascade order, low to high priority: tag/universal rules, class rules,
//! id rules, inline `style=""` -- each pass applied in source order so a
//! later same-specificity rule overrides an earlier one, which is a
//! reasonable approximation of real CSS specificity without computing it.
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use crate::dom::{Dom, Tag};
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum Display {
Block,
Inline,
None,
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum Align {
Left,
Center,
Right,
}
#[derive(Clone, Copy, Debug)]
pub struct Style {
pub display: Display,
pub color: Option<u32>,
pub background: Option<u32>,
pub width: Option<u32>,
pub height: Option<u32>,
pub margin: u32,
pub padding: u32,
pub bold: bool,
pub align: Align,
}
impl Style {
pub fn default_for(tag: Tag) -> Style {
let display = match tag {
Tag::Span | Tag::A | Tag::B | Tag::I | Tag::Text | Tag::Br => Display::Inline,
Tag::Script | Tag::Style | Tag::Title | Tag::Head => Display::None,
_ => Display::Block,
};
let bold = matches!(tag, Tag::B | Tag::H1 | Tag::H2 | Tag::H3);
let margin = match tag {
Tag::P | Tag::Div | Tag::Ul | Tag::Ol | Tag::H1 | Tag::H2 | Tag::H3 | Tag::H4 | Tag::H5 | Tag::H6 | Tag::Pre | Tag::Canvas => 6,
_ => 0,
};
Style { display, color: None, background: None, width: None, height: None, margin, padding: 0, bold, align: Align::Left }
}
}
enum Selector {
Tag(Tag),
Class(String),
Id(String),
Universal,
}
struct Rule {
selector: Selector,
decls: Vec<(String, String)>,
}
/// Collect every `<style>` block's text (source order) into one sheet, parse
/// it, and cascade it plus each element's inline `style=""` onto a
/// `Style` per DOM node, indexed the same as `dom.nodes`.
pub fn cascade(dom: &Dom) -> Vec<Style> {
let mut styles: Vec<Style> = dom.nodes.iter().map(|n| Style::default_for(n.tag)).collect();
let mut sheet = String::new();
collect_style_text(dom, dom.root, &mut sheet);
let rules = parse_stylesheet(&sheet);
for pass in 0..3u8 {
for rule in &rules {
let matches_pass = match (&rule.selector, pass) {
(Selector::Tag(_), 0) | (Selector::Universal, 0) => true,
(Selector::Class(_), 1) => true,
(Selector::Id(_), 2) => true,
_ => false,
};
if !matches_pass {
continue;
}
for (idx, node) in dom.nodes.iter().enumerate() {
if selector_matches(&rule.selector, node.tag, node.attr("class"), node.attr("id")) {
for (k, v) in &rule.decls {
apply_decl(&mut styles[idx], k, v);
}
}
}
}
}
// Inline style="" always wins last.
for (idx, node) in dom.nodes.iter().enumerate() {
if let Some(inline) = node.attr("style") {
for (k, v) in parse_decls(inline) {
apply_decl(&mut styles[idx], &k, &v);
}
}
}
styles
}
fn collect_style_text(dom: &Dom, idx: usize, out: &mut String) {
let node = &dom.nodes[idx];
if node.tag == Tag::Style {
out.push_str(&node.text);
out.push('\n');
}
for &c in &node.children {
collect_style_text(dom, c, out);
}
}
fn selector_matches(sel: &Selector, tag: Tag, class: Option<&str>, id: Option<&str>) -> bool {
match sel {
Selector::Universal => true,
Selector::Tag(t) => *t == tag,
Selector::Class(c) => class.map(|cl| cl.split_ascii_whitespace().any(|w| w == c)).unwrap_or(false),
Selector::Id(i) => id.map(|v| v == i).unwrap_or(false),
}
}
fn parse_stylesheet(css: &str) -> Vec<Rule> {
let mut rules = Vec::new();
let bytes = css.as_bytes();
let n = bytes.len();
let mut i = 0usize;
while i < n {
while i < n && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i >= n {
break;
}
if bytes[i..].starts_with(b"/*") {
i = match find(&bytes[i + 2..], b"*/") {
Some(end) => i + 2 + end + 2,
None => n,
};
continue;
}
let sel_start = i;
while i < n && bytes[i] != b'{' {
i += 1;
}
if i >= n {
break;
}
let sel_str = core::str::from_utf8(&bytes[sel_start..i]).unwrap_or("").trim();
i += 1;
let decl_start = i;
while i < n && bytes[i] != b'}' {
i += 1;
}
let decl_str = core::str::from_utf8(&bytes[decl_start..i.min(n)]).unwrap_or("");
if i < n {
i += 1;
}
let decls = parse_decls(decl_str);
if decls.is_empty() {
continue;
}
for token in sel_str.split(',') {
if let Some(sel) = parse_selector(token) {
rules.push(Rule { selector: sel, decls: decls.clone() });
}
}
}
rules
}
fn parse_selector(s: &str) -> Option<Selector> {
let s = s.trim();
if s.is_empty() {
return None;
}
if let Some(rest) = s.strip_prefix('.') {
return Some(Selector::Class(rest.to_string()));
}
if let Some(rest) = s.strip_prefix('#') {
return Some(Selector::Id(rest.to_string()));
}
if s == "*" {
return Some(Selector::Universal);
}
Some(Selector::Tag(Tag::from_name(s.as_bytes())))
}
fn parse_decls(s: &str) -> Vec<(String, String)> {
s.split(';')
.filter_map(|d| {
let d = d.trim();
if d.is_empty() {
return None;
}
let (k, v) = d.split_once(':')?;
Some((k.trim().to_ascii_lowercase(), v.trim().to_string()))
})
.collect()
}
fn apply_decl(style: &mut Style, key: &str, value: &str) {
match key {
"display" => {
style.display = match value {
"none" => Display::None,
"inline" => Display::Inline,
"block" => Display::Block,
_ => style.display,
};
},
"color" => {
if let Some(c) = parse_color(value) {
style.color = Some(c);
}
},
"background-color" | "background" => {
if let Some(c) = parse_color(value) {
style.background = Some(c);
}
},
"width" => {
if let Some(px) = parse_px(value) {
style.width = Some(px);
}
},
"height" => {
if let Some(px) = parse_px(value) {
style.height = Some(px);
}
},
"margin" => {
if let Some(px) = parse_px(value) {
style.margin = px;
}
},
"padding" => {
if let Some(px) = parse_px(value) {
style.padding = px;
}
},
"font-weight" => {
style.bold = value.eq_ignore_ascii_case("bold") || value == "700" || value == "600" || value == "800" || value == "900";
},
"text-align" => {
style.align = match value {
"center" => Align::Center,
"right" => Align::Right,
_ => Align::Left,
};
},
_ => {},
}
}
fn parse_px(v: &str) -> Option<u32> {
let v = v.trim();
let digits: String = v.chars().take_while(|c| c.is_ascii_digit()).collect();
if digits.is_empty() {
return None;
}
digits.parse::<u32>().ok()
}
/// `#rgb`, `#rrggbb`, `rgb(r,g,b)`, or a small named-color subset. Returns
/// `0x00RRGGBB`. Mirrors `bring-browser`'s color grammar exactly.
fn parse_color(s: &str) -> Option<u32> {
let s = s.trim();
if s.is_empty() {
return None;
}
let b = s.as_bytes();
let hexval = |c: u8| -> Option<u32> {
match c {
b'0'..=b'9' => Some((c - b'0') as u32),
b'a'..=b'f' => Some((c - b'a' + 10) as u32),
b'A'..=b'F' => Some((c - b'A' + 10) as u32),
_ => None,
}
};
if b[0] == b'#' {
let h = &b[1..];
if h.len() == 3 {
let r = hexval(h[0])?;
let g = hexval(h[1])?;
let bl = hexval(h[2])?;
return Some((r * 17) << 16 | (g * 17) << 8 | (bl * 17));
} else if h.len() == 6 {
let r = hexval(h[0])? * 16 + hexval(h[1])?;
let g = hexval(h[2])? * 16 + hexval(h[3])?;
let bl = hexval(h[4])? * 16 + hexval(h[5])?;
return Some(r << 16 | g << 8 | bl);
}
return None;
}
if s.len() >= 4 && s[..4].eq_ignore_ascii_case("rgb(") {
let inner = &b[4..b.len().saturating_sub(1)];
let mut vals = [0u32; 3];
let mut vi = 0usize;
let mut cur = 0u32;
let mut any = false;
for &c in inner {
match c {
b'0'..=b'9' => {
cur = cur * 10 + (c - b'0') as u32;
any = true;
},
b',' => {
if vi < 3 {
vals[vi] = cur.min(255);
vi += 1;
}
cur = 0;
any = false;
},
_ => {},
}
}
if any && vi < 3 {
vals[vi] = cur.min(255);
vi += 1;
}
if vi == 3 {
return Some(vals[0] << 16 | vals[1] << 8 | vals[2]);
}
return None;
}
let named: [(&str, u32); 12] = [
("black", 0x000000),
("white", 0xFFFFFF),
("red", 0xFF0000),
("green", 0x008000),
("blue", 0x0000FF),
("gray", 0x808080),
("grey", 0x808080),
("silver", 0xC0C0C0),
("yellow", 0xFFFF00),
("orange", 0xFFA500),
("purple", 0x800080),
("navy", 0x000080),
];
for (name, v) in named {
if s.eq_ignore_ascii_case(name) {
return Some(v);
}
}
None
}
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
if needle.is_empty() || haystack.len() < needle.len() {
return None;
}
(0..=haystack.len() - needle.len()).find(|&i| &haystack[i..i + needle.len()] == needle)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dom;
#[test]
fn inline_style_wins_over_stylesheet() {
let dom = dom::parse(b"<style>p{color:red}</style><p style=\"color:#00ff00\">x</p>");
let styles = cascade(&dom);
let p = dom.nodes[dom.root].children[1];
assert_eq!(styles[p].color, Some(0x00ff00));
}
#[test]
fn class_selector_beats_tag_selector() {
let dom = dom::parse(b"<style>p{color:red} .hi{color:blue}</style><p class=\"hi\">x</p>");
let styles = cascade(&dom);
let p = dom.nodes[dom.root].children[1];
assert_eq!(styles[p].color, Some(0x0000FF));
}
#[test]
fn id_selector_beats_class_selector() {
let dom = dom::parse(b"<style>.hi{color:blue} #x{color:#fff}</style><p class=\"hi\" id=\"x\">x</p>");
let styles = cascade(&dom);
let p = dom.nodes[dom.root].children[1];
assert_eq!(styles[p].color, Some(0xFFFFFF));
}
#[test]
fn display_none_is_parsed() {
let dom = dom::parse(b"<style>.hidden{display:none}</style><div class=\"hidden\">x</div>");
let styles = cascade(&dom);
let div = dom.nodes[dom.root].children[1];
assert_eq!(styles[div].display, Display::None);
}
#[test]
fn hex_and_named_colors_agree() {
assert_eq!(parse_color("red"), parse_color("#ff0000"));
assert_eq!(parse_color("#0f0"), Some(0x00FF00));
assert_eq!(parse_color("rgb(1,2,3)"), Some(0x010203));
}
}