Skip to main content

epub_stream/
css.rs

1//! CSS subset parser for EPUB styling
2//!
3//! Parses a minimal subset of CSS sufficient for EPUB rendering:
4//! - Font properties: `font-size`, `font-family`, `font-weight`, `font-style`
5//! - Text: `text-align`, `line-height`, `letter-spacing`
6//! - Spacing: `margin-top`, `margin-bottom`
7//! - Selectors: tag, class, and inline `style` attributes
8//!
9//! Complex selectors, floats, positioning, and grid are out of scope.
10
11extern crate alloc;
12
13use alloc::string::String;
14use alloc::vec::Vec;
15
16use crate::error::EpubError;
17
18/// Line height value
19#[derive(Clone, Debug, PartialEq)]
20#[non_exhaustive]
21pub enum LineHeight {
22    /// Absolute height in pixels
23    Px(f32),
24    /// Multiplier relative to font size (e.g., 1.5 = 1.5x)
25    Multiplier(f32),
26}
27
28/// Font size value
29#[derive(Clone, Copy, Debug, PartialEq)]
30#[non_exhaustive]
31pub enum FontSize {
32    /// Absolute size in pixels
33    Px(f32),
34    /// Relative size in em units
35    Em(f32),
36}
37
38/// Font weight
39#[derive(Clone, Copy, Debug, PartialEq, Default)]
40#[non_exhaustive]
41pub enum FontWeight {
42    /// Normal weight (400)
43    #[default]
44    Normal,
45    /// Bold weight (700)
46    Bold,
47}
48
49/// Font style
50#[derive(Clone, Copy, Debug, PartialEq, Default)]
51#[non_exhaustive]
52pub enum FontStyle {
53    /// Upright text
54    #[default]
55    Normal,
56    /// Italic text
57    Italic,
58}
59
60/// Text alignment
61#[derive(Clone, Copy, Debug, PartialEq, Default)]
62#[non_exhaustive]
63pub enum TextAlign {
64    /// Left-aligned (default for LTR)
65    #[default]
66    Left,
67    /// Centered
68    Center,
69    /// Right-aligned
70    Right,
71    /// Justified
72    Justify,
73}
74
75/// A set of CSS property values
76///
77/// All fields are optional — `None` means "not specified" (inherit from parent
78/// or use default).
79#[derive(Clone, Debug, Default, PartialEq)]
80pub struct CssStyle {
81    /// Font size
82    pub font_size: Option<FontSize>,
83    /// Font family name
84    pub font_family: Option<String>,
85    /// Font weight (normal or bold)
86    pub font_weight: Option<FontWeight>,
87    /// Font style (normal or italic)
88    pub font_style: Option<FontStyle>,
89    /// Text alignment
90    pub text_align: Option<TextAlign>,
91    /// Line height
92    pub line_height: Option<LineHeight>,
93    /// Letter spacing in pixels
94    pub letter_spacing: Option<f32>,
95    /// Top margin in pixels
96    pub margin_top: Option<f32>,
97    /// Bottom margin in pixels
98    pub margin_bottom: Option<f32>,
99}
100
101impl CssStyle {
102    /// Create an empty style (all properties unset)
103    pub fn new() -> Self {
104        Self::default()
105    }
106
107    /// Check if any property is set
108    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    /// Merge another style into this one (other's values take precedence)
121    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/// A CSS selector (subset)
153#[derive(Clone, Debug, PartialEq)]
154#[non_exhaustive]
155pub enum CssSelector {
156    /// Tag selector (e.g., `p`, `h1`)
157    Tag(String),
158    /// Class selector (e.g., `.chapter-title`)
159    Class(String),
160    /// Tag + class selector (e.g., `p.intro`)
161    TagClass(String, String),
162}
163
164impl CssSelector {
165    /// Check if this selector matches a given tag name and class list
166    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/// A single CSS rule (selector + declarations)
176#[derive(Clone, Debug, PartialEq)]
177pub struct CssRule {
178    /// The selector for this rule
179    pub selector: CssSelector,
180    /// The style declarations
181    pub style: CssStyle,
182}
183
184/// A parsed CSS stylesheet
185#[derive(Clone, Debug, Default, PartialEq)]
186pub struct Stylesheet {
187    /// All rules in document order
188    pub rules: Vec<CssRule>,
189}
190
191impl Stylesheet {
192    /// Create an empty stylesheet
193    pub fn new() -> Self {
194        Self::default()
195    }
196
197    /// Resolve the computed style for an element given its tag and classes
198    ///
199    /// Applies matching rules in document order (later rules override).
200    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    /// Get the number of rules
211    pub fn len(&self) -> usize {
212        self.rules.len()
213    }
214
215    /// Check if the stylesheet is empty
216    pub fn is_empty(&self) -> bool {
217        self.rules.is_empty()
218    }
219}
220
221/// Parse a CSS stylesheet string into a `Stylesheet`
222///
223/// Handles the v1 subset: tag selectors, class selectors, tag.class selectors,
224/// and the supported property set.
225pub 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        // Skip whitespace and comments
232        pos = skip_whitespace_and_comments(css, pos);
233        if pos >= bytes.len() {
234            break;
235        }
236
237        // Find selector (everything up to '{')
238        let brace_start = match css[pos..].find('{') {
239            Some(i) => pos + i,
240            None => break, // No more rules
241        };
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        // Parse selector
249        let selector = parse_selector(selector_str)?;
250
251        // Find closing brace
252        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        // Parse declarations
258        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
271/// Parse an inline `style` attribute value into a `CssStyle`
272///
273/// Example: `"font-weight: bold; margin-top: 10px"`
274pub fn parse_inline_style(style_attr: &str) -> Result<CssStyle, EpubError> {
275    parse_declarations(style_attr)
276}
277
278// -- Internal parsing helpers -------------------------------------------------
279
280/// Skip whitespace and CSS comments (`/* ... */`)
281fn 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            // Skip comment
288            match css[pos + 2..].find("*/") {
289                Some(end) => pos = pos + 2 + end + 2,
290                None => return bytes.len(), // Unterminated comment
291            }
292        } else {
293            break;
294        }
295    }
296    pos
297}
298
299/// Parse a single CSS selector string
300fn parse_selector(s: &str) -> Result<CssSelector, EpubError> {
301    let s = s.trim();
302
303    if let Some(class) = s.strip_prefix('.') {
304        // Class selector
305        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        // Tag.class selector
311        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        // Tag selector
319        if s.is_empty() {
320            return Err(EpubError::Css("Empty selector".into()));
321        }
322        Ok(CssSelector::Tag(s.into()))
323    }
324}
325
326/// Parse CSS declarations (the part inside `{ ... }`)
327fn 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, // Malformed declaration, skip
339        };
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                // Strip quotes from font family name
350                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                // Shorthand: only handle single-value case for now
394                if let Some(val) = parse_px_value(value) {
395                    style.margin_top = Some(val);
396                    style.margin_bottom = Some(val);
397                }
398            }
399            _ => {
400                // Unsupported property — silently ignored
401            }
402        }
403    }
404
405    Ok(style)
406}
407
408/// Parse a font-size value (px or em)
409fn 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
420/// Parse a line-height value (px or unitless multiplier)
421fn 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 // Use default
427    } else {
428        // Bare number = multiplier
429        value.parse::<f32>().ok().map(LineHeight::Multiplier)
430    }
431}
432
433/// Parse a letter-spacing value (`normal` or `<number>px`)
434fn 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
445/// Parse a pixel value (e.g., "10px" -> Some(10.0))
446fn 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        // Try bare number
454        value.parse::<f32>().ok()
455    }
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461
462    // -- CssStyle tests ---
463
464    #[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)); // kept
493        assert_eq!(base.text_align, Some(TextAlign::Center)); // overridden
494        assert_eq!(base.font_size, Some(FontSize::Px(16.0))); // added
495    }
496
497    // -- CssSelector tests ---
498
499    #[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    // -- Stylesheet parsing tests ---
523
524    #[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        // color and display are silently ignored
640    }
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        // "normal" maps to None, making the style empty, so no rule is added
685        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    // -- Additional edge case tests ---
745
746    #[test]
747    fn test_multiple_classes_in_selector() {
748        // Parser only handles single class; "p.a.b" should parse the first dot split
749        // The selector parser finds the first dot: tag="p", class="a.b"
750        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        // Resolve should apply rules in document order: later overrides earlier
769        let style = ss.resolve("p", &[]);
770        assert_eq!(style.font_weight, Some(FontWeight::Normal)); // overridden
771        assert_eq!(style.text_align, Some(TextAlign::Left)); // kept from first
772        assert_eq!(style.font_style, Some(FontStyle::Italic)); // added by second
773    }
774
775    #[test]
776    fn test_font_weight_numeric_values() {
777        // 400 = normal
778        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        // 700 = bold
783        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        // 800 = bold
788        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        // 900 = bold
793        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        // Empty declarations produce an empty style, which is not added
803        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        // CSS font-family can have multiple fallbacks separated by commas
827        // Our parser takes everything after the colon as the value, then trims quotes
828        // So it will get the full string. Let's verify behavior:
829        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        // Double-quoted
834        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        // All values should be overridden by overlay
869        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(); // all None
888        base.merge(&overlay);
889
890        // Base values should be preserved
891        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        // Spot-check first and last rules
915        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        // Spot-check middle
923        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        // Tag.class with hyphens
940        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        // Element with multiple classes
992        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}