1extern crate alloc;
12
13use alloc::string::String;
14use alloc::vec::Vec;
15
16use crate::error::EpubError;
17
18#[derive(Clone, Debug, PartialEq)]
20#[non_exhaustive]
21pub enum LineHeight {
22 Px(f32),
24 Multiplier(f32),
26}
27
28#[derive(Clone, Copy, Debug, PartialEq)]
30#[non_exhaustive]
31pub enum FontSize {
32 Px(f32),
34 Em(f32),
36}
37
38#[derive(Clone, Copy, Debug, PartialEq, Default)]
40#[non_exhaustive]
41pub enum FontWeight {
42 #[default]
44 Normal,
45 Bold,
47}
48
49#[derive(Clone, Copy, Debug, PartialEq, Default)]
51#[non_exhaustive]
52pub enum FontStyle {
53 #[default]
55 Normal,
56 Italic,
58}
59
60#[derive(Clone, Copy, Debug, PartialEq, Default)]
62#[non_exhaustive]
63pub enum TextAlign {
64 #[default]
66 Left,
67 Center,
69 Right,
71 Justify,
73}
74
75#[derive(Clone, Debug, Default, PartialEq)]
80pub struct CssStyle {
81 pub font_size: Option<FontSize>,
83 pub font_family: Option<String>,
85 pub font_weight: Option<FontWeight>,
87 pub font_style: Option<FontStyle>,
89 pub text_align: Option<TextAlign>,
91 pub line_height: Option<LineHeight>,
93 pub letter_spacing: Option<f32>,
95 pub margin_top: Option<f32>,
97 pub margin_bottom: Option<f32>,
99}
100
101impl CssStyle {
102 pub fn new() -> Self {
104 Self::default()
105 }
106
107 pub fn is_empty(&self) -> bool {
109 self.font_size.is_none()
110 && self.font_family.is_none()
111 && self.font_weight.is_none()
112 && self.font_style.is_none()
113 && self.text_align.is_none()
114 && self.line_height.is_none()
115 && self.letter_spacing.is_none()
116 && self.margin_top.is_none()
117 && self.margin_bottom.is_none()
118 }
119
120 pub fn merge(&mut self, other: &CssStyle) {
122 if other.font_size.is_some() {
123 self.font_size = other.font_size;
124 }
125 if other.font_family.is_some() {
126 self.font_family = other.font_family.clone();
127 }
128 if other.font_weight.is_some() {
129 self.font_weight = other.font_weight;
130 }
131 if other.font_style.is_some() {
132 self.font_style = other.font_style;
133 }
134 if other.text_align.is_some() {
135 self.text_align = other.text_align;
136 }
137 if other.line_height.is_some() {
138 self.line_height = other.line_height.clone();
139 }
140 if other.letter_spacing.is_some() {
141 self.letter_spacing = other.letter_spacing;
142 }
143 if other.margin_top.is_some() {
144 self.margin_top = other.margin_top;
145 }
146 if other.margin_bottom.is_some() {
147 self.margin_bottom = other.margin_bottom;
148 }
149 }
150}
151
152#[derive(Clone, Debug, PartialEq)]
154#[non_exhaustive]
155pub enum CssSelector {
156 Tag(String),
158 Class(String),
160 TagClass(String, String),
162}
163
164impl CssSelector {
165 pub fn matches(&self, tag: &str, classes: &[&str]) -> bool {
167 match self {
168 CssSelector::Tag(t) => t == tag,
169 CssSelector::Class(c) => classes.contains(&c.as_str()),
170 CssSelector::TagClass(t, c) => t == tag && classes.contains(&c.as_str()),
171 }
172 }
173}
174
175#[derive(Clone, Debug, PartialEq)]
177pub struct CssRule {
178 pub selector: CssSelector,
180 pub style: CssStyle,
182}
183
184#[derive(Clone, Debug, Default, PartialEq)]
186pub struct Stylesheet {
187 pub rules: Vec<CssRule>,
189}
190
191impl Stylesheet {
192 pub fn new() -> Self {
194 Self::default()
195 }
196
197 pub fn resolve(&self, tag: &str, classes: &[&str]) -> CssStyle {
201 let mut style = CssStyle::new();
202 for rule in &self.rules {
203 if rule.selector.matches(tag, classes) {
204 style.merge(&rule.style);
205 }
206 }
207 style
208 }
209
210 pub fn len(&self) -> usize {
212 self.rules.len()
213 }
214
215 pub fn is_empty(&self) -> bool {
217 self.rules.is_empty()
218 }
219}
220
221pub fn parse_stylesheet(css: &str) -> Result<Stylesheet, EpubError> {
226 let mut stylesheet = Stylesheet::new();
227 let mut pos = 0;
228 let bytes = css.as_bytes();
229
230 while pos < bytes.len() {
231 pos = skip_whitespace_and_comments(css, pos);
233 if pos >= bytes.len() {
234 break;
235 }
236
237 let brace_start = match css[pos..].find('{') {
239 Some(i) => pos + i,
240 None => break, };
242 let selector_str = css[pos..brace_start].trim();
243 if selector_str.is_empty() {
244 pos = brace_start + 1;
245 continue;
246 }
247
248 let selector = parse_selector(selector_str)?;
250
251 let brace_end = match css[brace_start + 1..].find('}') {
253 Some(i) => brace_start + 1 + i,
254 None => return Err(EpubError::Css("Unclosed CSS rule block".into())),
255 };
256
257 let declarations = &css[brace_start + 1..brace_end];
259 let style = parse_declarations(declarations)?;
260
261 if !style.is_empty() {
262 stylesheet.rules.push(CssRule { selector, style });
263 }
264
265 pos = brace_end + 1;
266 }
267
268 Ok(stylesheet)
269}
270
271pub fn parse_inline_style(style_attr: &str) -> Result<CssStyle, EpubError> {
275 parse_declarations(style_attr)
276}
277
278fn skip_whitespace_and_comments(css: &str, mut pos: usize) -> usize {
282 let bytes = css.as_bytes();
283 while pos < bytes.len() {
284 if bytes[pos].is_ascii_whitespace() {
285 pos += 1;
286 } else if pos + 1 < bytes.len() && bytes[pos] == b'/' && bytes[pos + 1] == b'*' {
287 match css[pos + 2..].find("*/") {
289 Some(end) => pos = pos + 2 + end + 2,
290 None => return bytes.len(), }
292 } else {
293 break;
294 }
295 }
296 pos
297}
298
299fn parse_selector(s: &str) -> Result<CssSelector, EpubError> {
301 let s = s.trim();
302
303 if let Some(class) = s.strip_prefix('.') {
304 if class.is_empty() {
306 return Err(EpubError::Css("Empty class selector".into()));
307 }
308 Ok(CssSelector::Class(class.into()))
309 } else if let Some(dot_pos) = s.find('.') {
310 let tag = &s[..dot_pos];
312 let class = &s[dot_pos + 1..];
313 if tag.is_empty() || class.is_empty() {
314 return Err(EpubError::Css(alloc::format!("Invalid selector: {}", s)));
315 }
316 Ok(CssSelector::TagClass(tag.into(), class.into()))
317 } else {
318 if s.is_empty() {
320 return Err(EpubError::Css("Empty selector".into()));
321 }
322 Ok(CssSelector::Tag(s.into()))
323 }
324}
325
326fn parse_declarations(declarations: &str) -> Result<CssStyle, EpubError> {
328 let mut style = CssStyle::new();
329
330 for decl in declarations.split(';') {
331 let decl = decl.trim();
332 if decl.is_empty() {
333 continue;
334 }
335
336 let colon_pos = match decl.find(':') {
337 Some(pos) => pos,
338 None => continue, };
340
341 let property = decl[..colon_pos].trim().to_lowercase();
342 let value = decl[colon_pos + 1..].trim();
343
344 match property.as_str() {
345 "font-size" => {
346 style.font_size = parse_font_size(value);
347 }
348 "font-family" => {
349 let family = value.trim_matches(|c| c == '\'' || c == '"');
351 if !family.is_empty() {
352 style.font_family = Some(family.into());
353 }
354 }
355 "font-weight" => {
356 style.font_weight = match value.to_lowercase().as_str() {
357 "bold" | "700" | "800" | "900" => Some(FontWeight::Bold),
358 "normal" | "400" => Some(FontWeight::Normal),
359 _ => None,
360 };
361 }
362 "font-style" => {
363 style.font_style = match value.to_lowercase().as_str() {
364 "italic" | "oblique" => Some(FontStyle::Italic),
365 "normal" => Some(FontStyle::Normal),
366 _ => None,
367 };
368 }
369 "text-align" => {
370 style.text_align = match value.to_lowercase().as_str() {
371 "left" => Some(TextAlign::Left),
372 "center" => Some(TextAlign::Center),
373 "right" => Some(TextAlign::Right),
374 "justify" => Some(TextAlign::Justify),
375 _ => None,
376 };
377 }
378 "line-height" => {
379 style.line_height = parse_line_height(value);
380 }
381 "letter-spacing" => {
382 if let Some(letter_spacing) = parse_letter_spacing(value) {
383 style.letter_spacing = Some(letter_spacing);
384 }
385 }
386 "margin-top" => {
387 style.margin_top = parse_px_value(value);
388 }
389 "margin-bottom" => {
390 style.margin_bottom = parse_px_value(value);
391 }
392 "margin" => {
393 if let Some(val) = parse_px_value(value) {
395 style.margin_top = Some(val);
396 style.margin_bottom = Some(val);
397 }
398 }
399 _ => {
400 }
402 }
403 }
404
405 Ok(style)
406}
407
408fn parse_font_size(value: &str) -> Option<FontSize> {
410 let value = value.trim().to_lowercase();
411 if let Some(px_str) = value.strip_suffix("px") {
412 px_str.trim().parse::<f32>().ok().map(FontSize::Px)
413 } else if let Some(em_str) = value.strip_suffix("em") {
414 em_str.trim().parse::<f32>().ok().map(FontSize::Em)
415 } else {
416 None
417 }
418}
419
420fn parse_line_height(value: &str) -> Option<LineHeight> {
422 let value = value.trim().to_lowercase();
423 if let Some(px_str) = value.strip_suffix("px") {
424 px_str.trim().parse::<f32>().ok().map(LineHeight::Px)
425 } else if value == "normal" {
426 None } else {
428 value.parse::<f32>().ok().map(LineHeight::Multiplier)
430 }
431}
432
433fn parse_letter_spacing(value: &str) -> Option<f32> {
435 let value = value.trim().to_lowercase();
436 if value == "normal" {
437 Some(0.0)
438 } else if let Some(px_str) = value.strip_suffix("px") {
439 px_str.trim().parse::<f32>().ok()
440 } else {
441 None
442 }
443}
444
445fn parse_px_value(value: &str) -> Option<f32> {
447 let value = value.trim().to_lowercase();
448 if let Some(px_str) = value.strip_suffix("px") {
449 px_str.trim().parse::<f32>().ok()
450 } else if value == "0" {
451 Some(0.0)
452 } else {
453 value.parse::<f32>().ok()
455 }
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461
462 #[test]
465 fn test_css_style_default_is_empty() {
466 let style = CssStyle::new();
467 assert!(style.is_empty());
468 }
469
470 #[test]
471 fn test_css_style_with_letter_spacing_is_not_empty() {
472 let style = CssStyle {
473 letter_spacing: Some(0.0),
474 ..Default::default()
475 };
476 assert!(!style.is_empty());
477 }
478
479 #[test]
480 fn test_css_style_merge() {
481 let mut base = CssStyle {
482 font_weight: Some(FontWeight::Bold),
483 text_align: Some(TextAlign::Left),
484 ..Default::default()
485 };
486 let overlay = CssStyle {
487 text_align: Some(TextAlign::Center),
488 font_size: Some(FontSize::Px(16.0)),
489 ..Default::default()
490 };
491 base.merge(&overlay);
492 assert_eq!(base.font_weight, Some(FontWeight::Bold)); assert_eq!(base.text_align, Some(TextAlign::Center)); assert_eq!(base.font_size, Some(FontSize::Px(16.0))); }
496
497 #[test]
500 fn test_selector_matches_tag() {
501 let sel = CssSelector::Tag("p".into());
502 assert!(sel.matches("p", &[]));
503 assert!(!sel.matches("h1", &[]));
504 }
505
506 #[test]
507 fn test_selector_matches_class() {
508 let sel = CssSelector::Class("intro".into());
509 assert!(sel.matches("p", &["intro"]));
510 assert!(sel.matches("div", &["intro", "other"]));
511 assert!(!sel.matches("p", &["other"]));
512 }
513
514 #[test]
515 fn test_selector_matches_tag_class() {
516 let sel = CssSelector::TagClass("p".into(), "intro".into());
517 assert!(sel.matches("p", &["intro"]));
518 assert!(!sel.matches("div", &["intro"]));
519 assert!(!sel.matches("p", &["other"]));
520 }
521
522 #[test]
525 fn test_parse_empty_stylesheet() {
526 let ss = parse_stylesheet("").unwrap();
527 assert!(ss.is_empty());
528 }
529
530 #[test]
531 fn test_parse_tag_rule() {
532 let css = "p { font-weight: bold; }";
533 let ss = parse_stylesheet(css).unwrap();
534 assert_eq!(ss.len(), 1);
535 assert_eq!(ss.rules[0].selector, CssSelector::Tag("p".into()));
536 assert_eq!(ss.rules[0].style.font_weight, Some(FontWeight::Bold));
537 }
538
539 #[test]
540 fn test_parse_class_rule() {
541 let css = ".chapter-title { font-size: 24px; text-align: center; }";
542 let ss = parse_stylesheet(css).unwrap();
543 assert_eq!(ss.len(), 1);
544 assert_eq!(
545 ss.rules[0].selector,
546 CssSelector::Class("chapter-title".into())
547 );
548 assert_eq!(ss.rules[0].style.font_size, Some(FontSize::Px(24.0)));
549 assert_eq!(ss.rules[0].style.text_align, Some(TextAlign::Center));
550 }
551
552 #[test]
553 fn test_parse_tag_class_rule() {
554 let css = "p.intro { font-style: italic; margin-top: 10px; }";
555 let ss = parse_stylesheet(css).unwrap();
556 assert_eq!(ss.len(), 1);
557 assert_eq!(
558 ss.rules[0].selector,
559 CssSelector::TagClass("p".into(), "intro".into())
560 );
561 assert_eq!(ss.rules[0].style.font_style, Some(FontStyle::Italic));
562 assert_eq!(ss.rules[0].style.margin_top, Some(10.0));
563 }
564
565 #[test]
566 fn test_parse_multiple_rules() {
567 let css = r#"
568 h1 { font-weight: bold; font-size: 24px; }
569 p { margin-bottom: 8px; }
570 .note { font-style: italic; }
571 "#;
572 let ss = parse_stylesheet(css).unwrap();
573 assert_eq!(ss.len(), 3);
574 }
575
576 #[test]
577 fn test_parse_font_size_px() {
578 let css = "p { font-size: 16px; }";
579 let ss = parse_stylesheet(css).unwrap();
580 assert_eq!(ss.rules[0].style.font_size, Some(FontSize::Px(16.0)));
581 }
582
583 #[test]
584 fn test_parse_font_size_em() {
585 let css = "p { font-size: 1.5em; }";
586 let ss = parse_stylesheet(css).unwrap();
587 assert_eq!(ss.rules[0].style.font_size, Some(FontSize::Em(1.5)));
588 }
589
590 #[test]
591 fn test_parse_font_family() {
592 let css = "p { font-family: 'Georgia'; }";
593 let ss = parse_stylesheet(css).unwrap();
594 assert_eq!(ss.rules[0].style.font_family, Some("Georgia".into()));
595 }
596
597 #[test]
598 fn test_parse_text_align_values() {
599 for (value, expected) in [
600 ("left", TextAlign::Left),
601 ("center", TextAlign::Center),
602 ("right", TextAlign::Right),
603 ("justify", TextAlign::Justify),
604 ] {
605 let css = alloc::format!("p {{ text-align: {}; }}", value);
606 let ss = parse_stylesheet(&css).unwrap();
607 assert_eq!(ss.rules[0].style.text_align, Some(expected));
608 }
609 }
610
611 #[test]
612 fn test_parse_margin_shorthand() {
613 let css = "p { margin: 12px; }";
614 let ss = parse_stylesheet(css).unwrap();
615 assert_eq!(ss.rules[0].style.margin_top, Some(12.0));
616 assert_eq!(ss.rules[0].style.margin_bottom, Some(12.0));
617 }
618
619 #[test]
620 fn test_parse_inline_style() {
621 let style = parse_inline_style("font-weight: bold; font-size: 14px").unwrap();
622 assert_eq!(style.font_weight, Some(FontWeight::Bold));
623 assert_eq!(style.font_size, Some(FontSize::Px(14.0)));
624 }
625
626 #[test]
627 fn test_css_comments_skipped() {
628 let css = "/* comment */ p { font-weight: bold; } /* another */";
629 let ss = parse_stylesheet(css).unwrap();
630 assert_eq!(ss.len(), 1);
631 assert_eq!(ss.rules[0].style.font_weight, Some(FontWeight::Bold));
632 }
633
634 #[test]
635 fn test_unknown_properties_ignored() {
636 let css = "p { color: red; font-weight: bold; display: flex; }";
637 let ss = parse_stylesheet(css).unwrap();
638 assert_eq!(ss.rules[0].style.font_weight, Some(FontWeight::Bold));
639 }
641
642 #[test]
643 fn test_resolve_style() {
644 let css = r#"
645 p { margin-bottom: 8px; }
646 .bold { font-weight: bold; }
647 p.intro { font-style: italic; }
648 "#;
649 let ss = parse_stylesheet(css).unwrap();
650
651 let style = ss.resolve("p", &["intro"]);
652 assert_eq!(style.margin_bottom, Some(8.0));
653 assert_eq!(style.font_style, Some(FontStyle::Italic));
654
655 let style = ss.resolve("p", &["bold"]);
656 assert_eq!(style.margin_bottom, Some(8.0));
657 assert_eq!(style.font_weight, Some(FontWeight::Bold));
658
659 let style = ss.resolve("div", &[]);
660 assert!(style.is_empty());
661 }
662
663 #[test]
664 fn test_parse_line_height_px() {
665 let css = "p { line-height: 24px; }";
666 let ss = parse_stylesheet(css).unwrap();
667 assert_eq!(ss.rules[0].style.line_height, Some(LineHeight::Px(24.0)));
668 }
669
670 #[test]
671 fn test_parse_line_height_multiplier() {
672 let css = "p { line-height: 1.5; }";
673 let ss = parse_stylesheet(css).unwrap();
674 assert_eq!(
675 ss.rules[0].style.line_height,
676 Some(LineHeight::Multiplier(1.5))
677 );
678 }
679
680 #[test]
681 fn test_parse_line_height_normal() {
682 let css = "p { line-height: normal; }";
683 let ss = parse_stylesheet(css).unwrap();
684 assert!(ss.is_empty());
686 }
687
688 #[test]
689 fn test_parse_line_height_normal_with_other_props() {
690 let css = "p { line-height: normal; font-weight: bold; }";
691 let ss = parse_stylesheet(css).unwrap();
692 assert_eq!(ss.len(), 1);
693 assert_eq!(ss.rules[0].style.line_height, None);
694 assert_eq!(ss.rules[0].style.font_weight, Some(FontWeight::Bold));
695 }
696
697 #[test]
698 fn test_parse_letter_spacing_px() {
699 let css = "p { letter-spacing: 1.25px; }";
700 let ss = parse_stylesheet(css).unwrap();
701 assert_eq!(ss.rules[0].style.letter_spacing, Some(1.25));
702 }
703
704 #[test]
705 fn test_parse_letter_spacing_normal() {
706 let css = "p { letter-spacing: normal; font-weight: bold; }";
707 let ss = parse_stylesheet(css).unwrap();
708 assert_eq!(ss.len(), 1);
709 assert_eq!(ss.rules[0].style.letter_spacing, Some(0.0));
710 assert_eq!(ss.rules[0].style.font_weight, Some(FontWeight::Bold));
711 }
712
713 #[test]
714 fn test_parse_letter_spacing_unsupported_unit_ignored() {
715 let css = "p { letter-spacing: 0.2em; font-weight: bold; }";
716 let ss = parse_stylesheet(css).unwrap();
717 assert_eq!(ss.len(), 1);
718 assert_eq!(ss.rules[0].style.letter_spacing, None);
719 assert_eq!(ss.rules[0].style.font_weight, Some(FontWeight::Bold));
720 }
721
722 #[test]
723 fn test_parse_letter_spacing_invalid_does_not_clear_previous_valid_value() {
724 let css = "p { letter-spacing: 1px; letter-spacing: 0.2em; }";
725 let ss = parse_stylesheet(css).unwrap();
726 assert_eq!(ss.len(), 1);
727 assert_eq!(ss.rules[0].style.letter_spacing, Some(1.0));
728 }
729
730 #[test]
731 fn test_parse_zero_margin() {
732 let css = "p { margin-top: 0; }";
733 let ss = parse_stylesheet(css).unwrap();
734 assert_eq!(ss.rules[0].style.margin_top, Some(0.0));
735 }
736
737 #[test]
738 fn test_unclosed_rule_error() {
739 let css = "p { font-weight: bold;";
740 let result = parse_stylesheet(css);
741 assert!(result.is_err());
742 }
743
744 #[test]
747 fn test_multiple_classes_in_selector() {
748 let css = ".first-class { font-weight: bold; }";
751 let ss = parse_stylesheet(css).unwrap();
752 assert_eq!(ss.len(), 1);
753 assert_eq!(
754 ss.rules[0].selector,
755 CssSelector::Class("first-class".into())
756 );
757 }
758
759 #[test]
760 fn test_cascading_later_rules_override() {
761 let css = r#"
762 p { font-weight: bold; text-align: left; }
763 p { font-weight: normal; font-style: italic; }
764 "#;
765 let ss = parse_stylesheet(css).unwrap();
766 assert_eq!(ss.len(), 2);
767
768 let style = ss.resolve("p", &[]);
770 assert_eq!(style.font_weight, Some(FontWeight::Normal)); assert_eq!(style.text_align, Some(TextAlign::Left)); assert_eq!(style.font_style, Some(FontStyle::Italic)); }
774
775 #[test]
776 fn test_font_weight_numeric_values() {
777 let css400 = "p { font-weight: 400; }";
779 let ss = parse_stylesheet(css400).unwrap();
780 assert_eq!(ss.rules[0].style.font_weight, Some(FontWeight::Normal));
781
782 let css700 = "p { font-weight: 700; }";
784 let ss = parse_stylesheet(css700).unwrap();
785 assert_eq!(ss.rules[0].style.font_weight, Some(FontWeight::Bold));
786
787 let css800 = "p { font-weight: 800; }";
789 let ss = parse_stylesheet(css800).unwrap();
790 assert_eq!(ss.rules[0].style.font_weight, Some(FontWeight::Bold));
791
792 let css900 = "p { font-weight: 900; }";
794 let ss = parse_stylesheet(css900).unwrap();
795 assert_eq!(ss.rules[0].style.font_weight, Some(FontWeight::Bold));
796 }
797
798 #[test]
799 fn test_empty_declarations() {
800 let css = "p { }";
801 let ss = parse_stylesheet(css).unwrap();
802 assert_eq!(ss.len(), 0);
804 }
805
806 #[test]
807 fn test_whitespace_variations_no_spaces() {
808 let css = "p{font-weight:bold}";
809 let ss = parse_stylesheet(css).unwrap();
810 assert_eq!(ss.len(), 1);
811 assert_eq!(ss.rules[0].selector, CssSelector::Tag("p".into()));
812 assert_eq!(ss.rules[0].style.font_weight, Some(FontWeight::Bold));
813 }
814
815 #[test]
816 fn test_whitespace_variations_extra_spaces() {
817 let css = " p { font-weight : bold ; font-size : 12px ; } ";
818 let ss = parse_stylesheet(css).unwrap();
819 assert_eq!(ss.len(), 1);
820 assert_eq!(ss.rules[0].style.font_weight, Some(FontWeight::Bold));
821 assert_eq!(ss.rules[0].style.font_size, Some(FontSize::Px(12.0)));
822 }
823
824 #[test]
825 fn test_multiple_font_family_values_take_first() {
826 let css = r#"p { font-family: 'Georgia'; }"#;
830 let ss = parse_stylesheet(css).unwrap();
831 assert_eq!(ss.rules[0].style.font_family, Some("Georgia".into()));
832
833 let css2 = r#"p { font-family: "Times New Roman"; }"#;
835 let ss2 = parse_stylesheet(css2).unwrap();
836 assert_eq!(
837 ss2.rules[0].style.font_family,
838 Some("Times New Roman".into())
839 );
840 }
841
842 #[test]
843 fn test_css_style_merge_both_sides_same_property() {
844 let mut base = CssStyle {
845 font_weight: Some(FontWeight::Bold),
846 font_style: Some(FontStyle::Normal),
847 text_align: Some(TextAlign::Left),
848 margin_top: Some(10.0),
849 font_size: Some(FontSize::Px(16.0)),
850 font_family: Some("Arial".into()),
851 line_height: Some(LineHeight::Px(20.0)),
852 letter_spacing: Some(0.5),
853 margin_bottom: Some(5.0),
854 };
855 let overlay = CssStyle {
856 font_weight: Some(FontWeight::Normal),
857 font_style: Some(FontStyle::Italic),
858 text_align: Some(TextAlign::Center),
859 margin_top: Some(20.0),
860 font_size: Some(FontSize::Em(1.5)),
861 font_family: Some("Georgia".into()),
862 line_height: Some(LineHeight::Multiplier(1.5)),
863 letter_spacing: Some(2.0),
864 margin_bottom: Some(15.0),
865 };
866 base.merge(&overlay);
867
868 assert_eq!(base.font_weight, Some(FontWeight::Normal));
870 assert_eq!(base.font_style, Some(FontStyle::Italic));
871 assert_eq!(base.text_align, Some(TextAlign::Center));
872 assert_eq!(base.margin_top, Some(20.0));
873 assert_eq!(base.font_size, Some(FontSize::Em(1.5)));
874 assert_eq!(base.font_family, Some("Georgia".into()));
875 assert_eq!(base.line_height, Some(LineHeight::Multiplier(1.5)));
876 assert_eq!(base.letter_spacing, Some(2.0));
877 assert_eq!(base.margin_bottom, Some(15.0));
878 }
879
880 #[test]
881 fn test_css_style_merge_overlay_none_preserves_base() {
882 let mut base = CssStyle {
883 font_weight: Some(FontWeight::Bold),
884 font_size: Some(FontSize::Px(16.0)),
885 ..Default::default()
886 };
887 let overlay = CssStyle::new(); base.merge(&overlay);
889
890 assert_eq!(base.font_weight, Some(FontWeight::Bold));
892 assert_eq!(base.font_size, Some(FontSize::Px(16.0)));
893 }
894
895 #[test]
896 fn test_large_stylesheet() {
897 let css = r#"
898 h1 { font-weight: bold; font-size: 24px; }
899 h2 { font-weight: bold; font-size: 20px; }
900 h3 { font-weight: bold; font-size: 18px; }
901 h4 { font-weight: bold; font-size: 16px; }
902 h5 { font-weight: bold; font-size: 14px; }
903 h6 { font-weight: bold; font-size: 12px; }
904 p { margin-bottom: 8px; line-height: 24px; }
905 .note { font-style: italic; margin-top: 10px; }
906 .chapter-title { font-size: 28px; text-align: center; }
907 .epigraph { font-style: italic; text-align: right; }
908 .footnote { font-size: 10px; margin-top: 5px; }
909 blockquote { margin-top: 12px; margin-bottom: 12px; }
910 "#;
911 let ss = parse_stylesheet(css).unwrap();
912 assert_eq!(ss.len(), 12);
913
914 assert_eq!(ss.rules[0].selector, CssSelector::Tag("h1".into()));
916 assert_eq!(ss.rules[0].style.font_size, Some(FontSize::Px(24.0)));
917
918 assert_eq!(ss.rules[11].selector, CssSelector::Tag("blockquote".into()));
919 assert_eq!(ss.rules[11].style.margin_top, Some(12.0));
920 assert_eq!(ss.rules[11].style.margin_bottom, Some(12.0));
921
922 assert_eq!(
924 ss.rules[8].selector,
925 CssSelector::Class("chapter-title".into())
926 );
927 assert_eq!(ss.rules[8].style.font_size, Some(FontSize::Px(28.0)));
928 assert_eq!(ss.rules[8].style.text_align, Some(TextAlign::Center));
929 }
930
931 #[test]
932 fn test_selector_with_hyphens() {
933 let css = ".my-class { font-weight: bold; }";
934 let ss = parse_stylesheet(css).unwrap();
935 assert_eq!(ss.len(), 1);
936 assert_eq!(ss.rules[0].selector, CssSelector::Class("my-class".into()));
937 assert_eq!(ss.rules[0].style.font_weight, Some(FontWeight::Bold));
938
939 let css2 = "p.my-intro-class { font-style: italic; }";
941 let ss2 = parse_stylesheet(css2).unwrap();
942 assert_eq!(
943 ss2.rules[0].selector,
944 CssSelector::TagClass("p".into(), "my-intro-class".into())
945 );
946 }
947
948 #[test]
949 fn test_parse_inline_style_trailing_semicolon() {
950 let style = parse_inline_style("font-weight: bold; font-size: 14px;").unwrap();
951 assert_eq!(style.font_weight, Some(FontWeight::Bold));
952 assert_eq!(style.font_size, Some(FontSize::Px(14.0)));
953 }
954
955 #[test]
956 fn test_parse_inline_style_empty() {
957 let style = parse_inline_style("").unwrap();
958 assert!(style.is_empty());
959 }
960
961 #[test]
962 fn test_parse_inline_style_only_semicolons() {
963 let style = parse_inline_style(";;;").unwrap();
964 assert!(style.is_empty());
965 }
966
967 #[test]
968 fn test_font_style_oblique() {
969 let css = "p { font-style: oblique; }";
970 let ss = parse_stylesheet(css).unwrap();
971 assert_eq!(ss.rules[0].style.font_style, Some(FontStyle::Italic));
972 }
973
974 #[test]
975 fn test_resolve_no_matching_rules() {
976 let css = "h1 { font-weight: bold; }";
977 let ss = parse_stylesheet(css).unwrap();
978 let style = ss.resolve("p", &[]);
979 assert!(style.is_empty());
980 }
981
982 #[test]
983 fn test_resolve_multiple_matching_classes() {
984 let css = r#"
985 .bold { font-weight: bold; }
986 .italic { font-style: italic; }
987 .centered { text-align: center; }
988 "#;
989 let ss = parse_stylesheet(css).unwrap();
990
991 let style = ss.resolve("p", &["bold", "italic", "centered"]);
993 assert_eq!(style.font_weight, Some(FontWeight::Bold));
994 assert_eq!(style.font_style, Some(FontStyle::Italic));
995 assert_eq!(style.text_align, Some(TextAlign::Center));
996 }
997
998 #[test]
999 fn test_css_style_is_empty_with_single_property() {
1000 let style = CssStyle {
1001 font_weight: Some(FontWeight::Bold),
1002 ..Default::default()
1003 };
1004 assert!(!style.is_empty());
1005 }
1006
1007 #[test]
1008 fn test_stylesheet_new_is_empty() {
1009 let ss = Stylesheet::new();
1010 assert!(ss.is_empty());
1011 assert_eq!(ss.len(), 0);
1012 }
1013
1014 #[test]
1015 fn test_css_comments_between_rules() {
1016 let css = r#"
1017 h1 { font-weight: bold; }
1018 /* This is a comment between rules */
1019 p { font-style: italic; }
1020 /* Another comment */
1021 "#;
1022 let ss = parse_stylesheet(css).unwrap();
1023 assert_eq!(ss.len(), 2);
1024 assert_eq!(ss.rules[0].style.font_weight, Some(FontWeight::Bold));
1025 assert_eq!(ss.rules[1].style.font_style, Some(FontStyle::Italic));
1026 }
1027}