Skip to main content

oxidize_html/
styler.rs

1use std::collections::HashMap;
2
3use markup5ever_rcdom::{Handle, NodeData, RcDom};
4
5use crate::{
6    BorderSpec, ComputedStyle, Display, Edges, FontStyle, FontWeight, NodeId, Rgba, SizeValue,
7    StyledNode, TextAlign, TextDecoration, VerticalAlign,
8};
9
10#[derive(Debug, Clone, PartialEq)]
11struct CssRule {
12    selectors: Vec<String>,
13    declarations: Vec<(String, String)>,
14}
15
16#[derive(Default, Debug)]
17pub struct StyleEngine {
18    next_id: NodeId,
19    rules: Vec<CssRule>,
20}
21
22impl StyleEngine {
23    pub fn compute(&mut self, dom: &RcDom, debug: bool) -> StyledNode {
24        self.next_id = 0;
25        self.rules = Self::collect_stylesheet_rules(dom);
26        let root_style = ComputedStyle::default();
27
28        let root = self.visit(&dom.document, &root_style);
29        if debug {
30            println!("Debug style tree:");
31            println!();
32            print_style_tree(&root, 0);
33        }
34        root
35    }
36
37    fn visit(&mut self, handle: &Handle, inherited: &ComputedStyle) -> StyledNode {
38        let node_id = self.alloc_id(); // Predictable ID allocation
39
40        let mut style = inherited.clone();
41        let mut tag = None;
42        let mut attrs = HashMap::new();
43        let mut text = None;
44
45        match &handle.data {
46            NodeData::Document => {
47                // The root MUST be visible to process children
48                style.display = Display::Block;
49            }
50            NodeData::Element {
51                name, attrs: raw, ..
52            } => {
53                let tag_name = name.local.to_string().to_ascii_lowercase();
54                tag = Some(tag_name.clone());
55                for a in raw.borrow().iter() {
56                    attrs.insert(
57                        a.name.local.to_string().to_ascii_lowercase(),
58                        a.value.to_string(),
59                    );
60                }
61
62                style = base_style_with_inheritance(inherited);
63                apply_tag_defaults(&tag_name, &mut style, inherited.font_size);
64                self.apply_stylesheet_rules(&tag_name, &attrs, &mut style);
65                apply_attribute_fallbacks(&tag_name, &attrs, &mut style, inherited.font_size);
66                if let Some(inline) = attrs.get("style") {
67                    apply_inline_style(inline, &mut style, inherited.font_size);
68                }
69            }
70            NodeData::Text { contents } => {
71                let value = contents.borrow().to_string().replace('\u{00A0}', " ");
72                style.display = Display::Inline;
73                if !value.trim().is_empty() {
74                    text = Some(value);
75                }
76            }
77            _ => {
78                // Comments and Doctypes should be ignored
79                style.display = Display::None;
80            }
81        }
82
83        let mut children = Vec::new();
84        for child in handle.children.borrow().iter() {
85            let child_node = self.visit(child, &style);
86
87            if child_node.style.display != Display::None
88                && (child_node.text.is_some()
89                    || child_node.tag.is_some()
90                    || !child_node.children.is_empty())
91            {
92                children.push(child_node);
93            }
94        }
95
96        StyledNode {
97            node_id,
98            tag,
99            attrs,
100            text,
101            style,
102            children,
103        }
104    }
105
106    fn alloc_id(&mut self) -> NodeId {
107        let id = self.next_id;
108        self.next_id += 1;
109        id
110    }
111
112    fn collect_stylesheet_rules(dom: &RcDom) -> Vec<CssRule> {
113        let mut css = String::new();
114        collect_style_text(&dom.document, &mut css);
115        parse_stylesheet(&css)
116    }
117
118    fn apply_stylesheet_rules(
119        &self,
120        tag: &str,
121        attrs: &HashMap<String, String>,
122        style: &mut ComputedStyle,
123    ) {
124        for rule in &self.rules {
125            if !rule
126                .selectors
127                .iter()
128                .any(|s| selector_matches(s, tag, attrs))
129            {
130                continue;
131            }
132            let parent_font_size = style.font_size;
133            for (key, value) in &rule.declarations {
134                apply_style_declaration(key, value, style, parent_font_size);
135            }
136        }
137    }
138}
139
140fn collect_style_text(handle: &Handle, out: &mut String) {
141    if let NodeData::Element { name, .. } = &handle.data
142        && name.local.as_ref().eq_ignore_ascii_case("style")
143    {
144        for child in handle.children.borrow().iter() {
145            if let NodeData::Text { contents } = &child.data {
146                out.push_str(&contents.borrow());
147                out.push('\n');
148            }
149        }
150    }
151    for child in handle.children.borrow().iter() {
152        collect_style_text(child, out);
153    }
154}
155
156fn parse_stylesheet(css: &str) -> Vec<CssRule> {
157    let mut rules = Vec::new();
158    for block in css.split('}') {
159        let Some((selector_part, declarations_part)) = block.split_once('{') else {
160            continue;
161        };
162        let selectors: Vec<String> = selector_part
163            .split(',')
164            .map(|s| s.trim().to_ascii_lowercase())
165            .filter(|s| !s.is_empty())
166            .collect();
167        if selectors.is_empty() {
168            continue;
169        }
170        let declarations: Vec<(String, String)> = declarations_part
171            .split(';')
172            .filter_map(|d| {
173                let (k, v) = d.split_once(':')?;
174                let key = k.trim().to_ascii_lowercase();
175                let value = v.trim().to_string();
176                if key.is_empty() || value.is_empty() {
177                    None
178                } else {
179                    Some((key, value))
180                }
181            })
182            .collect();
183        rules.push(CssRule {
184            selectors,
185            declarations,
186        });
187    }
188    rules
189}
190
191fn selector_matches(selector: &str, tag: &str, attrs: &HashMap<String, String>) -> bool {
192    let selector = selector.trim().to_ascii_lowercase();
193    if selector == tag {
194        return true;
195    }
196    if let Some(class_name) = selector.strip_prefix('.') {
197        return has_class(attrs, class_name);
198    }
199    if let Some((sel_tag, class_name)) = selector.split_once('.') {
200        return sel_tag == tag && has_class(attrs, class_name);
201    }
202    false
203}
204
205fn has_class(attrs: &HashMap<String, String>, class_name: &str) -> bool {
206    attrs
207        .get("class")
208        .map(|classes| {
209            classes
210                .split_whitespace()
211                .any(|c| c.eq_ignore_ascii_case(class_name))
212        })
213        .unwrap_or(false)
214}
215
216fn base_style_with_inheritance(parent: &ComputedStyle) -> ComputedStyle {
217    let mut style = ComputedStyle::default();
218
219    // 1. Inherit Typography and Color
220    style.color = parent.color;
221    style.font_size = parent.font_size;
222    style.font_weight = parent.font_weight;
223    style.font_style = parent.font_style;
224    style.font_family = parent.font_family.clone();
225    style.line_height = parent.line_height;
226
227    // 2. Inherit Alignment
228    style.text_align = parent.text_align;
229    style.vertical_align = parent.vertical_align;
230    style
231}
232
233fn apply_tag_defaults(tag: &str, style: &mut ComputedStyle, parent_font_size: f32) {
234    match tag {
235        // Invisible metadata tags
236        "head" | "meta" | "title" | "script" | "style" | "link" => {
237            style.display = Display::None;
238        }
239
240        "br" => {
241            style.display = Display::Inline;
242        }
243
244        // Table structural wrappers
245        "tbody" | "thead" | "tfoot" => {
246            style.display = Display::Block;
247            style.margin = Edges::all(0.0);
248            style.padding = Edges::all(0.0);
249        }
250
251        // Block and Table elements
252        "html" | "body" | "div" | "section" | "article" | "table" | "tr" | "td" | "th" => {
253            style.display = match tag {
254                "table" => Display::Table,
255                "tr" => Display::TableRow,
256                "td" | "th" => Display::TableCell,
257                _ => Display::Block,
258            };
259
260            style.text_align = TextAlign::Left;
261
262            if matches!(tag, "td" | "th") {
263                style.vertical_align = VerticalAlign::Middle;
264                if tag == "th" {
265                    style.font_weight = FontWeight::Bold;
266                }
267            }
268
269            if matches!(tag, "html" | "body" | "table" | "tr") {
270                style.margin = Edges::all(0.0);
271                style.padding = Edges::all(0.0);
272            }
273        }
274
275        "span" | "font" => style.display = Display::Inline,
276
277        "a" => {
278            style.display = Display::Inline;
279            style.color = parse_color("#0000EE").unwrap_or(style.color);
280            style.text_decoration = TextDecoration::Underline;
281        }
282
283        "b" | "strong" => {
284            style.display = Display::Inline;
285            style.font_weight = FontWeight::Bold;
286        }
287
288        "i" | "em" => {
289            style.display = Display::Inline;
290            style.font_style = FontStyle::Italic;
291        }
292
293        "u" | "ins" => {
294            style.display = Display::Inline;
295            style.text_decoration = TextDecoration::Underline;
296        }
297
298        "h1" => {
299            style.display = Display::Block; // CRITICAL: Was missing
300            style.font_size = parent_font_size * 2.0;
301            style.font_weight = FontWeight::Bold;
302            style.line_height = style.font_size * 1.2;
303            style.margin = Edges::all(0.0);
304            style.margin.top = style.font_size * 0.67;
305            style.margin.bottom = style.font_size * 0.67;
306        }
307
308        "h2" => {
309            style.display = Display::Block;
310            style.font_size = parent_font_size * 1.5;
311            style.font_weight = FontWeight::Bold;
312            style.line_height = style.font_size * 1.2; // Added for safety
313            style.margin = Edges::all(0.0);
314            style.margin.top = parent_font_size * 0.75;
315            style.margin.bottom = parent_font_size * 0.75;
316        }
317
318        "h3" => {
319            style.display = Display::Block;
320            style.font_size = parent_font_size * 1.17;
321            style.font_weight = FontWeight::Bold;
322            style.line_height = style.font_size * 1.2; // Added for safety
323            style.margin = Edges::all(0.0);
324            style.margin.top = parent_font_size * 0.83;
325            style.margin.bottom = parent_font_size * 0.83;
326        }
327
328        "p" | "ul" => {
329            style.display = Display::Block;
330            style.margin = Edges::all(0.0);
331            style.margin.top = parent_font_size;
332            style.margin.bottom = parent_font_size;
333            if tag == "ul" {
334                style.padding.left = 40.0;
335            }
336        }
337
338        "li" => {
339            style.display = Display::ListItem;
340        }
341
342        "hr" => {
343            style.display = Display::Block;
344            style.margin = Edges::all(0.0);
345            style.margin.top = parent_font_size * 0.5;
346            style.margin.bottom = parent_font_size * 0.5;
347            style.border.top = BorderSpec {
348                width: 1.0,
349                color: Rgba::rgb(204, 204, 204),
350            };
351        }
352
353        "img" => style.display = Display::InlineBlock,
354        "small" => {
355            style.display = Display::Inline;
356            style.font_size = parent_font_size * 0.875;
357        }
358        "sub" | "sup" => {
359            style.display = Display::Inline;
360            style.font_size = parent_font_size * 0.75;
361        }
362        _ => {}
363    }
364}
365
366
367fn apply_attribute_fallbacks(
368    tag: &str,
369    attrs: &HashMap<String, String>,
370    style: &mut ComputedStyle,
371    parent_font_size: f32,
372) {
373    match tag {
374        "font" => {
375            if let Some(color) = attrs.get("color").and_then(|v| parse_color(v)) {
376                style.color = color;
377            }
378            if let Some(size) = attrs.get("size").and_then(|v| parse_html_font_size(v)) {
379                style.font_size = size;
380                style.line_height = size * 1.2;
381            }
382        }
383        "td" | "th" => {
384            if let Some(bg) = attrs.get("bgcolor").and_then(|v| parse_color(v)) {
385                style.background_color = Some(bg);
386            }
387            if let Some(width) = attrs
388                .get("width")
389                .and_then(|v| parse_size(v, parent_font_size))
390            {
391                style.width = width;
392            }
393            if let Some(align) = attrs.get("align").and_then(|v| parse_text_align(v)) {
394                style.text_align = align;
395            }
396            if let Some(valign) = attrs.get("valign").and_then(|v| parse_vertical_align(v)) {
397                style.vertical_align = valign;
398            }
399            if tag == "th" {
400                style.font_weight = FontWeight::Bold;
401            }
402        }
403        "img" => {
404            if let Some(width) = attrs
405                .get("width")
406                .and_then(|v| parse_size(v, parent_font_size))
407            {
408                style.width = width;
409            }
410            if let Some(height) = attrs
411                .get("height")
412                .and_then(|v| parse_size(v, parent_font_size))
413            {
414                style.height = height;
415            }
416        }
417        "a" => {
418            if let Some(href) = attrs.get("href") {
419                style.href = Some(href.clone());
420            }
421        }
422        _ => {}
423    }
424}
425
426fn apply_inline_style(input: &str, style: &mut ComputedStyle, parent_font_size: f32) {
427    for declaration in input.split(';') {
428        let mut kv = declaration.splitn(2, ':');
429        let key = kv
430            .next()
431            .map(str::trim)
432            .unwrap_or_default()
433            .to_ascii_lowercase();
434        let value = kv.next().map(str::trim).unwrap_or_default();
435        if key.is_empty() || value.is_empty() {
436            continue;
437        }
438        apply_style_declaration(&key, value, style, parent_font_size);
439    }
440    if style.line_height <= 0.0 {
441        style.line_height = style.font_size * 1.2;
442    }
443}
444
445fn apply_style_declaration(
446    key: &str,
447    value: &str,
448    style: &mut ComputedStyle,
449    parent_font_size: f32,
450) {
451    match key {
452        "color" => {
453            if let Some(color) = parse_color(value) {
454                style.color = color;
455            }
456        }
457        "background-color" => {
458            style.background_color = parse_color(value);
459        }
460        "font-size" => {
461            if let Some(px) = parse_font_size(value, parent_font_size) {
462                style.font_size = px;
463            }
464        }
465        "font-weight" => {
466            if let Some(w) = parse_font_weight(value) {
467                style.font_weight = w;
468            }
469        }
470        "font-style" => {
471            if value.eq_ignore_ascii_case("italic") {
472                style.font_style = FontStyle::Italic;
473            }
474        }
475        "font-family" => {
476            style.font_family = value
477                .split(',')
478                .map(|v| v.trim().trim_matches('"').trim_matches('\'').to_string())
479                .filter(|v| !v.is_empty())
480                .collect();
481        }
482        "text-align" => {
483            if let Some(align) = parse_text_align(value) {
484                style.text_align = align;
485            }
486        }
487        "line-height" => {
488            if let Some(line_height) = parse_line_height(value, style.font_size) {
489                style.line_height = line_height;
490            }
491        }
492        "padding" => style.padding = parse_edge_shorthand(value, parent_font_size),
493        "padding-top" => {
494            style.padding.top =
495                parse_length_like(value, parent_font_size).unwrap_or(style.padding.top)
496        }
497        "padding-right" => {
498            style.padding.right =
499                parse_length_like(value, parent_font_size).unwrap_or(style.padding.right)
500        }
501        "padding-bottom" => {
502            style.padding.bottom =
503                parse_length_like(value, parent_font_size).unwrap_or(style.padding.bottom)
504        }
505        "padding-left" => {
506            style.padding.left =
507                parse_length_like(value, parent_font_size).unwrap_or(style.padding.left)
508        }
509        "margin" => style.margin = parse_edge_shorthand(value, parent_font_size),
510        "margin-top" => {
511            style.margin.top =
512                parse_length_like(value, parent_font_size).unwrap_or(style.margin.top)
513        }
514        "margin-right" => {
515            style.margin.right =
516                parse_length_like(value, parent_font_size).unwrap_or(style.margin.right)
517        }
518        "margin-bottom" => {
519            style.margin.bottom =
520                parse_length_like(value, parent_font_size).unwrap_or(style.margin.bottom)
521        }
522        "margin-left" => {
523            style.margin.left =
524                parse_length_like(value, parent_font_size).unwrap_or(style.margin.left)
525        }
526        "width" => style.width = parse_size(value, parent_font_size).unwrap_or(style.width),
527        "height" => style.height = parse_size(value, parent_font_size).unwrap_or(style.height),
528        "display" => style.display = parse_display(value).unwrap_or(style.display),
529        "vertical-align" => {
530            if let Some(va) = parse_vertical_align(value) {
531                style.vertical_align = va;
532            }
533        }
534        "text-decoration" => {
535            style.text_decoration = if value.eq_ignore_ascii_case("underline") {
536                TextDecoration::Underline
537            } else {
538                TextDecoration::None
539            };
540        }
541        "border" => apply_border_shorthand(value, style, parent_font_size),
542        "border-top" => {
543            style.border.top =
544                parse_border_spec(value, parent_font_size).unwrap_or(style.border.top)
545        }
546        "border-right" => {
547            style.border.right =
548                parse_border_spec(value, parent_font_size).unwrap_or(style.border.right)
549        }
550        "border-bottom" => {
551            style.border.bottom =
552                parse_border_spec(value, parent_font_size).unwrap_or(style.border.bottom)
553        }
554        "border-left" => {
555            style.border.left =
556                parse_border_spec(value, parent_font_size).unwrap_or(style.border.left)
557        }
558        _ => {}
559    }
560}
561
562fn parse_edge_shorthand(value: &str, base_font_size: f32) -> Edges<f32> {
563    let nums: Vec<f32> = value
564        .split_whitespace()
565        .filter_map(|token| parse_length_like(token, base_font_size))
566        .collect();
567
568    match nums.as_slice() {
569        [v] => Edges::all(*v),
570        [v1, v2] => Edges {
571            top: *v1,
572            right: *v2,
573            bottom: *v1,
574            left: *v2,
575        },
576        [v1, v2, v3] => Edges {
577            top: *v1,
578            right: *v2,
579            bottom: *v3,
580            left: *v2,
581        },
582        [v1, v2, v3, v4] => Edges {
583            top: *v1,
584            right: *v2,
585            bottom: *v3,
586            left: *v4,
587        },
588        _ => Edges::all(0.0),
589    }
590}
591
592fn apply_border_shorthand(value: &str, style: &mut ComputedStyle, base_font_size: f32) {
593    if let Some(spec) = parse_border_spec(value, base_font_size) {
594        style.border = Edges::all(spec);
595    }
596}
597
598fn parse_border_spec(value: &str, base_font_size: f32) -> Option<BorderSpec> {
599    let mut width = None;
600    let mut color = None;
601    for token in value.split_whitespace() {
602        if width.is_none() {
603            width = parse_length_like(token, base_font_size);
604        }
605        if color.is_none() {
606            color = parse_color(token);
607        }
608    }
609    let width = width.unwrap_or(0.0);
610    let color = color.unwrap_or(Rgba::rgb(0, 0, 0));
611    if width > 0.0 {
612        Some(BorderSpec { width, color })
613    } else {
614        None
615    }
616}
617
618fn parse_display(value: &str) -> Option<Display> {
619    match value.trim().to_ascii_lowercase().as_str() {
620        "block" => Some(Display::Block),
621        "inline" => Some(Display::Inline),
622        "inline-block" => Some(Display::InlineBlock),
623        "none" => Some(Display::None),
624        _ => None,
625    }
626}
627
628fn parse_html_font_size(value: &str) -> Option<f32> {
629    match value.trim().parse::<u8>().ok()? {
630        1 => Some(10.0),
631        2 => Some(13.0),
632        3 => Some(16.0),
633        4 => Some(18.0),
634        5 => Some(24.0),
635        6 => Some(32.0),
636        7 => Some(48.0),
637        _ => None,
638    }
639}
640
641fn parse_font_size(value: &str, parent_font_size: f32) -> Option<f32> {
642    let value = value.trim().to_ascii_lowercase();
643    if value.is_empty() {
644        return None;
645    }
646
647    match value.as_str() {
648        "xx-small" => Some(9.0),
649        "x-small" => Some(10.0),
650        "small" => Some(13.0),
651        "medium" => Some(16.0),
652        "large" => Some(18.0),
653        "x-large" => Some(24.0),
654        "xx-large" => Some(32.0),
655        _ if value.ends_with("px") => value.trim_end_matches("px").trim().parse().ok(),
656        _ if value.ends_with("pt") => {
657            // 1pt is roughly 1.33px
658            let pt: f32 = value.trim_end_matches("pt").trim().parse().ok()?;
659            Some(pt * 1.333)
660        }
661        _ if value.ends_with("em") || value.ends_with("rem") => {
662            let factor: f32 = value
663                .trim_end_matches("rem")
664                .trim_end_matches("em")
665                .trim()
666                .parse()
667                .ok()?;
668            Some(parent_font_size * factor)
669        }
670        _ if value.ends_with('%') => {
671            let pct: f32 = value.trim_end_matches('%').trim().parse().ok()?;
672            Some(parent_font_size * (pct / 100.0))
673        }
674        _ => value.parse::<f32>().ok(),
675    }
676}
677
678fn parse_font_weight(value: &str) -> Option<FontWeight> {
679    let value = value.trim();
680    if value.eq_ignore_ascii_case("normal") {
681        return Some(FontWeight::Normal);
682    }
683    if value.eq_ignore_ascii_case("bold") {
684        return Some(FontWeight::Bold);
685    }
686    value.parse::<u16>().ok().map(FontWeight::Weight)
687}
688
689fn parse_text_align(value: &str) -> Option<TextAlign> {
690    match value.trim().to_ascii_lowercase().as_str() {
691        "left" => Some(TextAlign::Left),
692        "center" => Some(TextAlign::Center),
693        "right" => Some(TextAlign::Right),
694        _ => None,
695    }
696}
697
698fn parse_vertical_align(value: &str) -> Option<VerticalAlign> {
699    match value.trim().to_ascii_lowercase().as_str() {
700        "top" => Some(VerticalAlign::Top),
701        "middle" => Some(VerticalAlign::Middle),
702        "bottom" => Some(VerticalAlign::Bottom),
703        "baseline" => Some(VerticalAlign::Baseline),
704        _ => None,
705    }
706}
707
708fn parse_line_height(value: &str, font_size: f32) -> Option<f32> {
709    let value = value.trim().to_ascii_lowercase();
710    if value.ends_with("px") {
711        value.trim_end_matches("px").parse().ok()
712    } else {
713        value.parse::<f32>().ok().map(|multiplier| {
714            if multiplier <= 4.0 {
715                multiplier * font_size
716            } else {
717                multiplier
718            }
719        })
720    }
721}
722
723fn parse_size(value: &str, base_font_size: f32) -> Option<SizeValue> {
724    let value = value.trim().to_ascii_lowercase();
725    if value == "auto" {
726        return Some(SizeValue::Auto);
727    }
728    if value.ends_with('%') {
729        return value
730            .trim_end_matches('%')
731            .parse::<f32>()
732            .ok()
733            .map(SizeValue::Percent);
734    }
735    parse_length_like(&value, base_font_size).map(SizeValue::Px)
736}
737
738fn print_style_tree(node: &StyledNode, indent: usize) {
739    let indent_str = "  ".repeat(indent);
740
741    // 1. Format the identity of the node
742    if let Some(tag) = &node.tag {
743        // Use brackets for tags to make them stand out
744        print!("{}[<{}>]", indent_str, tag);
745    } else if let Some(text) = &node.text {
746        // Truncate long text so it doesn't wrap and break the visual tree
747        let truncated: String = text.chars().take(40).collect();
748        let display_text = if text.len() > 40 {
749            format!("{}...", truncated)
750        } else {
751            truncated
752        };
753        print!("{}\"{}\"", indent_str, display_text.escape_debug());
754    } else {
755        print!("{}<anonymous>", indent_str);
756    }
757
758    // 2. Print only the "interesting" style properties
759    let s = &node.style;
760    let mut props = Vec::new();
761
762    if s.display != Display::Block {
763        props.push(format!("display:{:?}", s.display));
764    }
765    if let Some(bg) = s.background_color {
766        props.push(format!("bg:{:?}", bg));
767    }
768
769    // Only show margins/padding if they aren't zero
770    if s.margin.top != 0.0
771        || s.margin.bottom != 0.0
772        || s.margin.left != 0.0
773        || s.margin.right != 0.0
774    {
775        props.push(format!("margin:{:?}", s.margin));
776    }
777
778    if s.font_size != 16.0 {
779        props.push(format!("size:{}", s.font_size));
780    }
781    if s.font_weight != FontWeight::Normal {
782        props.push("bold".to_string());
783    }
784
785    if !props.is_empty() {
786        print!(" \x1b[33m-- {}\x1b[0m", props.join(", ")); // Yellow color for styles
787    }
788
789    println!(); // End line
790
791    // 3. Recurse
792    for child in &node.children {
793        print_style_tree(child, indent + 1);
794    }
795}
796
797fn parse_length_like(value: &str, base_font_size: f32) -> Option<f32> {
798    let value = value.trim().to_ascii_lowercase();
799    if value.ends_with("px") {
800        value.trim_end_matches("px").parse().ok()
801    } else if value.ends_with("em") {
802        let em = value.trim_end_matches("em").parse::<f32>().ok()?;
803        Some(em * base_font_size)
804    } else {
805        value.parse::<f32>().ok()
806    }
807}
808
809pub fn parse_color(value: &str) -> Option<Rgba> {
810    let value = value.trim().to_ascii_lowercase();
811    if value.starts_with('#') {
812        return parse_hex_color(&value);
813    }
814    if value.starts_with("rgb(") && value.ends_with(')') {
815        let parts: Vec<u8> = value
816            .trim_start_matches("rgb(")
817            .trim_end_matches(')')
818            .split(',')
819            .filter_map(|p| p.trim().parse::<u8>().ok())
820            .collect();
821        if let [r, g, b] = parts.as_slice() {
822            return Some(Rgba::rgb(*r, *g, *b));
823        }
824    }
825    named_color(&value)
826}
827
828fn parse_hex_color(value: &str) -> Option<Rgba> {
829    let hex = value.trim_start_matches('#');
830    match hex.len() {
831        3 => {
832            let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()?;
833            let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()?;
834            let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()?;
835            Some(Rgba::rgb(r, g, b))
836        }
837        6 => {
838            let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
839            let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
840            let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
841            Some(Rgba::rgb(r, g, b))
842        }
843        _ => None,
844    }
845}
846
847fn named_color(value: &str) -> Option<Rgba> {
848    let color = match value {
849        "black" => Rgba::rgb(0, 0, 0),
850        "white" => Rgba::rgb(255, 255, 255),
851        "red" => Rgba::rgb(255, 0, 0),
852        "green" => Rgba::rgb(0, 128, 0),
853        "blue" => Rgba::rgb(0, 0, 255),
854        "gray" | "grey" => Rgba::rgb(128, 128, 128),
855        "silver" => Rgba::rgb(192, 192, 192),
856        "maroon" => Rgba::rgb(128, 0, 0),
857        "yellow" => Rgba::rgb(255, 255, 0),
858        "teal" => Rgba::rgb(0, 128, 128),
859        "navy" => Rgba::rgb(0, 0, 128),
860        _ => return None,
861    };
862    Some(color)
863}
864
865#[cfg(test)]
866mod tests {
867    use super::StyleEngine;
868    use super::{parse_color, parse_font_size, parse_size};
869    use crate::{SizeValue, parser};
870
871    #[test]
872    fn parses_hex_and_rgb_colors() {
873        assert_eq!(parse_color("#fff").expect("color").r, 255);
874        assert_eq!(parse_color("rgb(10, 20, 30)").expect("color").g, 20);
875    }
876
877    #[test]
878    fn parses_font_size() {
879        assert_eq!(parse_font_size("1.5em", 16.0), Some(24.0));
880        assert_eq!(parse_font_size("small", 16.0), Some(13.0));
881    }
882
883    #[test]
884    fn parses_size_variants() {
885        assert_eq!(parse_size("100%", 16.0), Some(SizeValue::Percent(100.0)));
886        assert_eq!(parse_size("300", 16.0), Some(SizeValue::Px(300.0)));
887    }
888
889    #[test]
890    fn style_block_applies_to_elements() {
891        let dom = parser::parse("<style>p { color: #ff0000; }</style><p>hello</p>");
892        let mut engine = StyleEngine::default();
893        let tree = engine.compute(&dom, false);
894
895        fn find_first_tag<'a>(
896            node: &'a crate::StyledNode,
897            tag: &str,
898        ) -> Option<&'a crate::StyledNode> {
899            if node.tag.as_deref() == Some(tag) {
900                return Some(node);
901            }
902            for child in &node.children {
903                if let Some(found) = find_first_tag(child, tag) {
904                    return Some(found);
905                }
906            }
907            None
908        }
909
910        let p = find_first_tag(&tree, "p").expect("p");
911        assert_eq!(p.style.color.r, 255);
912    }
913}