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(); 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 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 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 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 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 "head" | "meta" | "title" | "script" | "style" | "link" => {
237 style.display = Display::None;
238 }
239
240 "br" => {
241 style.display = Display::Inline;
242 }
243
244 "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 "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; 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; 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; 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 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 if let Some(tag) = &node.tag {
743 print!("{}[<{}>]", indent_str, tag);
745 } else if let Some(text) = &node.text {
746 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 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 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(", ")); }
788
789 println!(); 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}