Skip to main content

graphitepdf_stylesheet/
lib.rs

1pub mod error;
2
3pub use error::*;
4
5use indexmap::IndexMap;
6
7const DEFAULT_DPI: f64 = 72.0;
8const DEFAULT_REM_BASE: f64 = 18.0;
9const MM_PER_INCH: f64 = 25.4;
10const CM_PER_INCH: f64 = 2.54;
11
12pub type Style = IndexMap<String, StyleValue>;
13pub type SafeStyle = Style;
14pub type ExpandedStyle = Style;
15
16#[derive(Clone, Debug, PartialEq)]
17pub enum StyleValue {
18    Null,
19    Bool(bool),
20    Number(f64),
21    String(String),
22    Array(Vec<StyleValue>),
23    Object(Style),
24}
25
26impl StyleValue {
27    pub fn as_object(&self) -> Option<&Style> {
28        match self {
29            Self::Object(style) => Some(style),
30            _ => None,
31        }
32    }
33
34    fn as_f64(&self) -> Option<f64> {
35        match self {
36            Self::Number(number) => Some(*number),
37            Self::String(text) => parse_float_like(text),
38            _ => None,
39        }
40    }
41
42    fn as_string(&self) -> Option<&str> {
43        match self {
44            Self::String(text) => Some(text),
45            _ => None,
46        }
47    }
48}
49
50impl Default for StyleValue {
51    fn default() -> Self {
52        Self::Object(Style::new())
53    }
54}
55
56impl From<bool> for StyleValue {
57    fn from(value: bool) -> Self {
58        Self::Bool(value)
59    }
60}
61
62impl From<f64> for StyleValue {
63    fn from(value: f64) -> Self {
64        Self::Number(value)
65    }
66}
67
68impl From<f32> for StyleValue {
69    fn from(value: f32) -> Self {
70        Self::Number(f64::from(value))
71    }
72}
73
74impl From<i64> for StyleValue {
75    fn from(value: i64) -> Self {
76        Self::Number(value as f64)
77    }
78}
79
80impl From<i32> for StyleValue {
81    fn from(value: i32) -> Self {
82        Self::Number(f64::from(value))
83    }
84}
85
86impl From<usize> for StyleValue {
87    fn from(value: usize) -> Self {
88        Self::Number(value as f64)
89    }
90}
91
92impl From<String> for StyleValue {
93    fn from(value: String) -> Self {
94        Self::String(value)
95    }
96}
97
98impl From<&str> for StyleValue {
99    fn from(value: &str) -> Self {
100        Self::String(value.to_string())
101    }
102}
103
104impl From<Vec<StyleValue>> for StyleValue {
105    fn from(value: Vec<StyleValue>) -> Self {
106        Self::Array(value)
107    }
108}
109
110impl From<Style> for StyleValue {
111    fn from(value: Style) -> Self {
112        Self::Object(value)
113    }
114}
115
116#[derive(Clone, Copy, Debug, PartialEq, Eq)]
117pub enum Orientation {
118    Landscape,
119    Portrait,
120}
121
122impl Orientation {
123    fn from_str(value: &str) -> Option<Self> {
124        match value.trim() {
125            "landscape" => Some(Self::Landscape),
126            "portrait" => Some(Self::Portrait),
127            _ => None,
128        }
129    }
130}
131
132#[derive(Clone, Copy, Debug, PartialEq)]
133pub struct Container {
134    pub width: f64,
135    pub height: f64,
136    pub dpi: Option<f64>,
137    pub rem_base: Option<f64>,
138    pub orientation: Option<Orientation>,
139}
140
141impl Container {
142    pub fn new(width: f64, height: f64) -> Self {
143        Self {
144            width,
145            height,
146            dpi: None,
147            rem_base: None,
148            orientation: None,
149        }
150    }
151
152    fn resolved_orientation(self) -> Orientation {
153        self.orientation.unwrap_or({
154            if self.width > self.height {
155                Orientation::Landscape
156            } else {
157                Orientation::Portrait
158            }
159        })
160    }
161}
162
163#[derive(Clone, Debug, PartialEq)]
164pub struct Stylesheet {
165    input: StyleValue,
166}
167
168impl Default for Stylesheet {
169    fn default() -> Self {
170        Self {
171            input: StyleValue::Object(Style::new()),
172        }
173    }
174}
175
176impl Stylesheet {
177    pub fn new(input: impl Into<StyleValue>) -> Self {
178        Self {
179            input: input.into(),
180        }
181    }
182
183    pub fn input(&self) -> &StyleValue {
184        &self.input
185    }
186
187    pub fn is_empty(&self) -> bool {
188        match &self.input {
189            StyleValue::Null => true,
190            StyleValue::Array(items) => items.is_empty(),
191            StyleValue::Object(style) => style.is_empty(),
192            _ => false,
193        }
194    }
195
196    pub fn resolve(&self, container: &Container) -> Style {
197        resolve_styles(container, &self.input)
198    }
199}
200
201pub fn flatten(input: &StyleValue) -> Style {
202    let mut flattened = Style::new();
203    flatten_into(input, &mut flattened);
204    flattened
205}
206
207pub fn resolve_media_queries(container: &Container, style: &Style) -> Style {
208    let mut resolved = Style::new();
209
210    for (key, value) in style {
211        if key.starts_with("@media") {
212            if matches_media_query(container, key)
213                && let StyleValue::Object(media_style) = value
214            {
215                merge_style(&mut resolved, media_style.clone());
216            }
217        } else {
218            resolved.insert(key.clone(), value.clone());
219        }
220    }
221
222    resolved
223}
224
225pub fn resolve_style(container: &Container, style: &Style) -> Style {
226    let mut resolved = Style::new();
227
228    for (key, value) in style {
229        if key.starts_with("@media") {
230            continue;
231        }
232
233        let expanded = resolve_property(key, value, container, style);
234        merge_style(&mut resolved, expanded);
235    }
236
237    resolved
238}
239
240pub fn resolve_styles(container: &Container, input: &StyleValue) -> Style {
241    let flattened = flatten(input);
242    let media_resolved = resolve_media_queries(container, &flattened);
243    resolve_style(container, &media_resolved)
244}
245
246pub fn transform_color(value: &str) -> String {
247    let trimmed = value.trim();
248
249    if let Some(hex) = parse_rgb_color(trimmed) {
250        return hex;
251    }
252
253    if let Some(hex) = parse_hsl_color(trimmed) {
254        return hex;
255    }
256
257    trimmed.to_string()
258}
259
260fn flatten_into(input: &StyleValue, flattened: &mut Style) {
261    match input {
262        StyleValue::Null => {}
263        StyleValue::Array(items) => {
264            for item in items {
265                flatten_into(item, flattened);
266            }
267        }
268        StyleValue::Object(style) => {
269            for (key, value) in style {
270                if !matches!(value, StyleValue::Null) {
271                    flattened.insert(key.clone(), value.clone());
272                }
273            }
274        }
275        StyleValue::Bool(_) | StyleValue::Number(_) | StyleValue::String(_) => {}
276    }
277}
278
279fn merge_style(target: &mut Style, source: Style) {
280    for (key, value) in source {
281        target.insert(key, value);
282    }
283}
284
285fn style_with(key: impl Into<String>, value: impl Into<StyleValue>) -> Style {
286    let mut style = Style::new();
287    style.insert(key.into(), value.into());
288    style
289}
290
291fn resolve_property(key: &str, value: &StyleValue, container: &Container, style: &Style) -> Style {
292    match key {
293        "backgroundColor" | "color" | "textDecorationColor" | "fill" | "stroke" => {
294            process_color_value(key, value)
295        }
296        "opacity" | "fillOpacity" | "strokeOpacity" | "aspectRatio" | "zIndex" | "maxLines"
297        | "flexGrow" | "flexShrink" => process_number_value(key, value),
298        "height"
299        | "maxHeight"
300        | "maxWidth"
301        | "minHeight"
302        | "minWidth"
303        | "width"
304        | "bottom"
305        | "left"
306        | "right"
307        | "top"
308        | "fontSize"
309        | "letterSpacing"
310        | "strokeWidth"
311        | "borderBottomLeftRadius"
312        | "borderBottomRightRadius"
313        | "borderBottomWidth"
314        | "borderLeftWidth"
315        | "borderRightWidth"
316        | "borderTopLeftRadius"
317        | "borderTopRightRadius"
318        | "borderTopWidth"
319        | "columnGap"
320        | "rowGap"
321        | "flexBasis" => process_unit_value(key, value, container),
322        "display"
323        | "position"
324        | "overflow"
325        | "direction"
326        | "fontFamily"
327        | "fontStyle"
328        | "textAlign"
329        | "textDecoration"
330        | "textDecorationStyle"
331        | "textIndent"
332        | "textOverflow"
333        | "textTransform"
334        | "verticalAlign"
335        | "alignContent"
336        | "alignItems"
337        | "alignSelf"
338        | "flexDirection"
339        | "flexFlow"
340        | "flexWrap"
341        | "justifyContent"
342        | "justifySelf"
343        | "objectFit"
344        | "strokeDasharray"
345        | "fillRule"
346        | "textAnchor"
347        | "strokeLinecap"
348        | "strokeLinejoin"
349        | "visibility"
350        | "clipPath"
351        | "dominantBaseline"
352        | "borderBottomStyle"
353        | "borderLeftStyle"
354        | "borderRightStyle"
355        | "borderTopStyle" => process_noop_value(key, value),
356        "fontWeight" => process_font_weight(value),
357        "lineHeight" => process_line_height(value, style, container),
358        "margin" => expand_margin(value, container),
359        "marginTop" | "marginRight" | "marginBottom" | "marginLeft" => {
360            expand_margin_single(key, value, container)
361        }
362        "marginHorizontal" => expand_margin_horizontal(value, container),
363        "marginVertical" => expand_margin_vertical(value, container),
364        "padding" => expand_padding(value, container),
365        "paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft" => {
366            expand_padding_single(key, value, container)
367        }
368        "paddingHorizontal" => expand_padding_horizontal(value, container),
369        "paddingVertical" => expand_padding_vertical(value, container),
370        "gap" => process_gap(value, container),
371        "flex" => process_flex(value, container),
372        "objectPosition" => process_object_position(value, container),
373        "objectPositionX" | "objectPositionY" => {
374            process_object_position_value(key, value, container)
375        }
376        "transform" | "gradientTransform" => process_transform(key, value),
377        "transformOrigin" => process_transform_origin(value, container),
378        "transformOriginX" | "transformOriginY" => {
379            process_transform_origin_value(key, value, container)
380        }
381        "border" | "borderTop" | "borderRight" | "borderBottom" | "borderLeft" | "borderColor"
382        | "borderStyle" | "borderWidth" | "borderRadius" => {
383            process_border_shorthand(key, value, container)
384        }
385        "borderBottomColor" | "borderLeftColor" | "borderRightColor" | "borderTopColor" => {
386            process_color_value(key, value)
387        }
388        _ => style_with(key, value.clone()),
389    }
390}
391
392fn process_noop_value(key: &str, value: &StyleValue) -> Style {
393    style_with(key, value.clone())
394}
395
396fn process_number_value(key: &str, value: &StyleValue) -> Style {
397    match value.as_f64() {
398        Some(number) => style_with(key, number),
399        None => Style::new(),
400    }
401}
402
403fn process_unit_value(key: &str, value: &StyleValue, container: &Container) -> Style {
404    style_with(key, transform_unit(container, value))
405}
406
407fn process_color_value(key: &str, value: &StyleValue) -> Style {
408    match value {
409        StyleValue::String(text) => style_with(key, transform_color(text)),
410        _ => style_with(key, value.clone()),
411    }
412}
413
414fn process_font_weight(value: &StyleValue) -> Style {
415    let weight = match value {
416        StyleValue::Number(number) => *number,
417        StyleValue::String(text) => match text.to_ascii_lowercase().as_str() {
418            "thin" | "hairline" => 100.0,
419            "ultralight" | "extralight" => 200.0,
420            "light" => 300.0,
421            "normal" => 400.0,
422            "medium" => 500.0,
423            "semibold" | "demibold" => 600.0,
424            "bold" => 700.0,
425            "ultrabold" | "extrabold" => 800.0,
426            "heavy" | "black" => 900.0,
427            _ => parse_int_like(text).map(f64::from).unwrap_or(400.0),
428        },
429        _ => 400.0,
430    };
431
432    style_with("fontWeight", weight)
433}
434
435fn process_line_height(value: &StyleValue, style: &Style, container: &Container) -> Style {
436    let font_size = style
437        .get("fontSize")
438        .map(|value| transform_unit(container, value))
439        .and_then(|value| match value {
440            StyleValue::Number(number) => Some(number),
441            _ => None,
442        })
443        .unwrap_or(DEFAULT_REM_BASE);
444
445    let resolved = match value {
446        StyleValue::String(text) => {
447            let trimmed = text.trim();
448            if trimmed.is_empty() {
449                StyleValue::String(String::new())
450            } else if let Some(percent) = parse_percent(trimmed) {
451                StyleValue::Number(percent * font_size)
452            } else if is_plain_number(trimmed) {
453                StyleValue::Number(parse_float_like(trimmed).unwrap_or(0.0) * font_size)
454            } else {
455                transform_unit(container, value)
456            }
457        }
458        StyleValue::Number(number) => StyleValue::Number(number * font_size),
459        _ => value.clone(),
460    };
461
462    style_with("lineHeight", resolved)
463}
464
465fn expand_margin(value: &StyleValue, container: &Container) -> Style {
466    expand_box_model(value, container, 4, true, |parts| {
467        style_from_pairs(vec![
468            ("marginTop", parts[0].clone()),
469            ("marginRight", parts[1].clone()),
470            ("marginBottom", parts[2].clone()),
471            ("marginLeft", parts[3].clone()),
472        ])
473    })
474}
475
476fn expand_margin_horizontal(value: &StyleValue, container: &Container) -> Style {
477    expand_box_model(value, container, 2, true, |parts| {
478        style_from_pairs(vec![
479            ("marginRight", parts[0].clone()),
480            ("marginLeft", parts[1].clone()),
481        ])
482    })
483}
484
485fn expand_margin_vertical(value: &StyleValue, container: &Container) -> Style {
486    expand_box_model(value, container, 2, true, |parts| {
487        style_from_pairs(vec![
488            ("marginTop", parts[0].clone()),
489            ("marginBottom", parts[1].clone()),
490        ])
491    })
492}
493
494fn expand_margin_single(key: &str, value: &StyleValue, container: &Container) -> Style {
495    expand_box_model(value, container, 1, true, |parts| {
496        style_with(key, parts[0].clone())
497    })
498}
499
500fn expand_padding(value: &StyleValue, container: &Container) -> Style {
501    expand_box_model(value, container, 4, false, |parts| {
502        style_from_pairs(vec![
503            ("paddingTop", parts[0].clone()),
504            ("paddingRight", parts[1].clone()),
505            ("paddingBottom", parts[2].clone()),
506            ("paddingLeft", parts[3].clone()),
507        ])
508    })
509}
510
511fn expand_padding_horizontal(value: &StyleValue, container: &Container) -> Style {
512    expand_box_model(value, container, 2, false, |parts| {
513        style_from_pairs(vec![
514            ("paddingRight", parts[0].clone()),
515            ("paddingLeft", parts[1].clone()),
516        ])
517    })
518}
519
520fn expand_padding_vertical(value: &StyleValue, container: &Container) -> Style {
521    expand_box_model(value, container, 2, false, |parts| {
522        style_from_pairs(vec![
523            ("paddingTop", parts[0].clone()),
524            ("paddingBottom", parts[1].clone()),
525        ])
526    })
527}
528
529fn expand_padding_single(key: &str, value: &StyleValue, container: &Container) -> Style {
530    expand_box_model(value, container, 1, false, |parts| {
531        style_with(key, parts[0].clone())
532    })
533}
534
535fn expand_box_model(
536    value: &StyleValue,
537    container: &Container,
538    max_values: usize,
539    auto_supported: bool,
540    builder: impl Fn([StyleValue; 4]) -> Style,
541) -> Style {
542    let Some(mut parts) = parse_box_model_parts(value, container, max_values, auto_supported)
543    else {
544        return Style::new();
545    };
546
547    let first = parts.remove(0);
548    let second = parts.first().cloned().unwrap_or_else(|| first.clone());
549    let third = parts.get(1).cloned().unwrap_or_else(|| first.clone());
550    let fourth = parts
551        .get(2)
552        .cloned()
553        .unwrap_or_else(|| parts.first().cloned().unwrap_or_else(|| first.clone()));
554
555    builder([first, second, third, fourth])
556}
557
558fn parse_box_model_parts(
559    value: &StyleValue,
560    container: &Container,
561    max_values: usize,
562    auto_supported: bool,
563) -> Option<Vec<StyleValue>> {
564    match value {
565        StyleValue::Number(number) => Some(vec![StyleValue::Number(*number)]),
566        StyleValue::String(text) => {
567            let trimmed = text.trim();
568            if trimmed.is_empty() || contains_unsupported_box_syntax(trimmed) {
569                return None;
570            }
571
572            let mut parts = Vec::new();
573            for token in trimmed.split_whitespace() {
574                if token == "auto" && auto_supported {
575                    parts.push(StyleValue::String("auto".to_string()));
576                    continue;
577                }
578
579                if !is_valid_box_model_token(token) {
580                    return None;
581                }
582
583                parts.push(transform_unit(
584                    container,
585                    &StyleValue::String(token.to_string()),
586                ));
587            }
588
589            if parts.is_empty() || parts.len() > max_values {
590                return None;
591            }
592
593            Some(parts)
594        }
595        _ => None,
596    }
597}
598
599fn contains_unsupported_box_syntax(text: &str) -> bool {
600    ['(', ')', '"', '\'', ',', '/']
601        .into_iter()
602        .any(|character| text.contains(character))
603}
604
605fn is_valid_box_model_token(token: &str) -> bool {
606    token.ends_with('%') || parse_length_token(token).is_some()
607}
608
609fn process_gap(value: &StyleValue, container: &Container) -> Style {
610    let parts = match value {
611        StyleValue::Number(_) => vec![value.clone()],
612        StyleValue::String(text) => text
613            .split_whitespace()
614            .map(|part| StyleValue::String(part.to_string()))
615            .collect(),
616        _ => return Style::new(),
617    };
618
619    if parts.is_empty() {
620        return Style::new();
621    }
622
623    let row_gap = transform_unit(container, &parts[0]);
624    let column_gap = transform_unit(container, parts.get(1).unwrap_or(&parts[0]));
625
626    style_from_pairs(vec![("rowGap", row_gap), ("columnGap", column_gap)])
627}
628
629fn process_flex(value: &StyleValue, container: &Container) -> Style {
630    let mut parts: Vec<String> = Vec::new();
631    let defaults: [&str; 3] = match value {
632        StyleValue::String(text) if text == "auto" => ["1", "1", "auto"],
633        StyleValue::String(text) if text == "none" => ["0", "0", "auto"],
634        StyleValue::String(text) if text == "initial" => ["0", "1", "auto"],
635        StyleValue::String(text) => {
636            parts = text.split_whitespace().map(ToOwned::to_owned).collect();
637            ["1", "1", "0"]
638        }
639        StyleValue::Number(number) => {
640            parts.push(number.to_string());
641            ["1", "1", "0"]
642        }
643        _ => return Style::new(),
644    };
645
646    let flex_grow =
647        parse_float_like(parts.first().map(String::as_str).unwrap_or(defaults[0])).unwrap_or(0.0);
648    let flex_shrink =
649        parse_float_like(parts.get(1).map(String::as_str).unwrap_or(defaults[1])).unwrap_or(0.0);
650    let flex_basis_input = parts
651        .get(2)
652        .map(|value| StyleValue::String(value.clone()))
653        .unwrap_or_else(|| StyleValue::String(defaults[2].to_string()));
654    let flex_basis = transform_unit(container, &flex_basis_input);
655
656    style_from_pairs(vec![
657        ("flexGrow", StyleValue::Number(flex_grow)),
658        ("flexShrink", StyleValue::Number(flex_shrink)),
659        ("flexBasis", flex_basis),
660    ])
661}
662
663fn process_object_position(value: &StyleValue, container: &Container) -> Style {
664    let StyleValue::String(text) = value else {
665        return Style::new();
666    };
667
668    let parts: Vec<&str> = text.split_whitespace().collect();
669    if parts.is_empty() {
670        return Style::new();
671    }
672
673    let (x_value, y_value) = if parts.len() == 1 {
674        if matches!(parts[0], "top" | "bottom") {
675            ("center", parts[0])
676        } else {
677            (parts[0], "center")
678        }
679    } else {
680        (parts[0], parts[1])
681    };
682
683    style_from_pairs(vec![
684        (
685            "objectPositionX",
686            offset_keyword(transform_unit(
687                container,
688                &StyleValue::String(x_value.to_string()),
689            )),
690        ),
691        (
692            "objectPositionY",
693            offset_keyword(transform_unit(
694                container,
695                &StyleValue::String(y_value.to_string()),
696            )),
697        ),
698    ])
699}
700
701fn process_object_position_value(key: &str, value: &StyleValue, container: &Container) -> Style {
702    style_with(key, offset_keyword(transform_unit(container, value)))
703}
704
705fn process_transform_origin(value: &StyleValue, container: &Container) -> Style {
706    let StyleValue::String(text) = value else {
707        return Style::new();
708    };
709
710    let parts: Vec<&str> = text.split_whitespace().collect();
711    let pair = transform_origin_pair(&parts);
712
713    style_from_pairs(vec![
714        (
715            "transformOriginX",
716            normalize_transform_origin_value(transform_unit(
717                container,
718                &StyleValue::String(pair.0.to_string()),
719            )),
720        ),
721        (
722            "transformOriginY",
723            normalize_transform_origin_value(transform_unit(
724                container,
725                &StyleValue::String(pair.1.to_string()),
726            )),
727        ),
728    ])
729}
730
731fn process_transform_origin_value(key: &str, value: &StyleValue, container: &Container) -> Style {
732    style_with(
733        key,
734        normalize_transform_origin_value(transform_unit(container, value)),
735    )
736}
737
738fn transform_origin_pair<'a>(parts: &'a [&'a str]) -> (&'a str, &'a str) {
739    if parts.is_empty() {
740        return ("center", "center");
741    }
742
743    let mut pair = if parts.len() == 1 {
744        [parts[0], "center"]
745    } else {
746        [parts[0], parts[1]]
747    };
748
749    if matches!(pair[0], "top" | "bottom") {
750        pair.swap(0, 1);
751    }
752
753    (pair[0], pair[1])
754}
755
756fn normalize_transform_origin_value(value: StyleValue) -> StyleValue {
757    let mapped = offset_keyword(value);
758    cast_float_value(mapped)
759}
760
761fn process_transform(key: &str, value: &StyleValue) -> Style {
762    match value {
763        StyleValue::String(text) => style_with(key, parse_transform(text)),
764        StyleValue::Array(_) => style_with(key, value.clone()),
765        _ => Style::new(),
766    }
767}
768
769fn parse_transform(input: &str) -> StyleValue {
770    let mut operations = Vec::new();
771    let mut remainder = input.trim();
772
773    if !remainder.contains('(') {
774        return StyleValue::Array(vec![
775            style_from_pairs(vec![
776                ("operation", StyleValue::String(remainder.to_string())),
777                ("value", StyleValue::Bool(true)),
778            ])
779            .into(),
780        ]);
781    }
782
783    while let Some(start) = remainder.find('(') {
784        let name = remainder[..start].trim();
785        let after_start = &remainder[start + 1..];
786        let Some(end) = after_start.find(')') else {
787            break;
788        };
789
790        let raw_values = after_start[..end].trim();
791        let values: Vec<&str> = if raw_values.contains(',') {
792            raw_values
793                .split(',')
794                .map(str::trim)
795                .filter(|value| !value.is_empty())
796                .collect()
797        } else {
798            raw_values
799                .split_whitespace()
800                .filter(|value| !value.is_empty())
801                .collect()
802        };
803
804        operations.push(normalize_transform_operation(name, &values).into());
805        remainder = after_start[end + 1..].trim();
806    }
807
808    StyleValue::Array(operations)
809}
810
811fn normalize_transform_operation(name: &str, values: &[&str]) -> Style {
812    match name {
813        "scale" => {
814            let x = parse_float_like(values.first().copied().unwrap_or("0")).unwrap_or(0.0);
815            let y = parse_float_like(
816                values
817                    .get(1)
818                    .copied()
819                    .unwrap_or(values.first().copied().unwrap_or("0")),
820            )
821            .unwrap_or(x);
822            transform_operation("scale", vec![x, y])
823        }
824        "scaleX" => {
825            let x = parse_float_like(values.first().copied().unwrap_or("0")).unwrap_or(0.0);
826            transform_operation("scale", vec![x, 1.0])
827        }
828        "scaleY" => {
829            let y = parse_float_like(values.first().copied().unwrap_or("0")).unwrap_or(0.0);
830            transform_operation("scale", vec![1.0, y])
831        }
832        "translate" => {
833            let x = parse_float_like(values.first().copied().unwrap_or("0")).unwrap_or(0.0);
834            let y = parse_float_like(values.get(1).copied().unwrap_or("0")).unwrap_or(0.0);
835            transform_operation("translate", vec![x, y])
836        }
837        "translateX" => {
838            let x = parse_float_like(values.first().copied().unwrap_or("0")).unwrap_or(0.0);
839            transform_operation("translate", vec![x, 0.0])
840        }
841        "translateY" => {
842            let y = parse_float_like(values.first().copied().unwrap_or("0")).unwrap_or(0.0);
843            transform_operation("translate", vec![0.0, y])
844        }
845        "rotate" => {
846            let angle = parse_angle(values.first().copied().unwrap_or("0"));
847            let cx = parse_float_like(values.get(1).copied().unwrap_or("0")).unwrap_or(0.0);
848            let cy = parse_float_like(values.get(2).copied().unwrap_or("0")).unwrap_or(0.0);
849            transform_operation("rotate", vec![angle, cx, cy])
850        }
851        "skew" => {
852            let parsed = values
853                .iter()
854                .map(|value| parse_angle(value))
855                .collect::<Vec<_>>();
856            transform_operation("skew", parsed)
857        }
858        "skewX" => {
859            let angle = parse_angle(values.first().copied().unwrap_or("0"));
860            transform_operation("skew", vec![angle, 0.0])
861        }
862        "skewY" => {
863            let angle = parse_angle(values.first().copied().unwrap_or("0"));
864            transform_operation("skew", vec![0.0, angle])
865        }
866        other => {
867            let parsed = values
868                .iter()
869                .map(|value| parse_float_like(value).unwrap_or(0.0))
870                .collect::<Vec<_>>();
871            transform_operation(other, parsed)
872        }
873    }
874}
875
876fn transform_operation(operation: &str, values: Vec<f64>) -> Style {
877    style_from_pairs(vec![
878        ("operation", operation.into()),
879        (
880            "value",
881            StyleValue::Array(values.into_iter().map(StyleValue::Number).collect()),
882        ),
883    ])
884}
885
886fn parse_angle(value: &str) -> f64 {
887    let trimmed = value.trim();
888    if let Some(number) = trimmed.strip_suffix("rad").and_then(parse_float_like) {
889        (number * 180.0) / std::f64::consts::PI
890    } else if let Some(number) = trimmed.strip_suffix("deg").and_then(parse_float_like) {
891        number
892    } else {
893        parse_float_like(trimmed).unwrap_or(0.0)
894    }
895}
896
897fn process_border_shorthand(key: &str, value: &StyleValue, container: &Container) -> Style {
898    let Some(text) = value.as_string() else {
899        return match key {
900            "borderWidth" | "borderRadius" => spread_value(key, transform_unit(container, value)),
901            _ => Style::new(),
902        };
903    };
904
905    let parts: Vec<&str> = text.split_whitespace().collect();
906    if parts.len() >= 3 {
907        let width = transform_unit(container, &StyleValue::String(parts[0].to_string()));
908        let style = StyleValue::String(parts[1].to_string());
909        let color = StyleValue::String(transform_color(&parts[2..].join(" ")));
910
911        return if matches!(
912            key,
913            "borderTop" | "borderRight" | "borderBottom" | "borderLeft"
914        ) {
915            let prefix = key;
916            style_from_pairs(vec![
917                (format!("{prefix}Color").as_str(), color),
918                (format!("{prefix}Style").as_str(), style),
919                (format!("{prefix}Width").as_str(), width),
920            ])
921        } else {
922            style_from_pairs(vec![
923                ("borderTopColor", color.clone()),
924                ("borderTopStyle", style.clone()),
925                ("borderTopWidth", width.clone()),
926                ("borderRightColor", color.clone()),
927                ("borderRightStyle", style.clone()),
928                ("borderRightWidth", width.clone()),
929                ("borderBottomColor", color.clone()),
930                ("borderBottomStyle", style.clone()),
931                ("borderBottomWidth", width.clone()),
932                ("borderLeftColor", color),
933                ("borderLeftStyle", style),
934                ("borderLeftWidth", width),
935            ])
936        };
937    }
938
939    match key {
940        "borderColor" => spread_border_color(value),
941        "borderStyle" => spread_border_style(value),
942        "borderWidth" => spread_border_width(transform_unit(container, value)),
943        "borderRadius" => spread_border_radius(transform_unit(container, value)),
944        _ => style_with(key, value.clone()),
945    }
946}
947
948fn spread_border_color(value: &StyleValue) -> Style {
949    let resolved = match value {
950        StyleValue::String(text) => StyleValue::String(transform_color(text)),
951        _ => value.clone(),
952    };
953
954    style_from_pairs(vec![
955        ("borderTopColor", resolved.clone()),
956        ("borderRightColor", resolved.clone()),
957        ("borderBottomColor", resolved.clone()),
958        ("borderLeftColor", resolved),
959    ])
960}
961
962fn spread_border_style(value: &StyleValue) -> Style {
963    style_from_pairs(vec![
964        ("borderTopStyle", value.clone()),
965        ("borderRightStyle", value.clone()),
966        ("borderBottomStyle", value.clone()),
967        ("borderLeftStyle", value.clone()),
968    ])
969}
970
971fn spread_border_width(value: StyleValue) -> Style {
972    style_from_pairs(vec![
973        ("borderTopWidth", value.clone()),
974        ("borderRightWidth", value.clone()),
975        ("borderBottomWidth", value.clone()),
976        ("borderLeftWidth", value),
977    ])
978}
979
980fn spread_border_radius(value: StyleValue) -> Style {
981    style_from_pairs(vec![
982        ("borderTopLeftRadius", value.clone()),
983        ("borderTopRightRadius", value.clone()),
984        ("borderBottomRightRadius", value.clone()),
985        ("borderBottomLeftRadius", value),
986    ])
987}
988
989fn spread_value(key: &str, value: StyleValue) -> Style {
990    match key {
991        "borderWidth" => spread_border_width(value),
992        "borderRadius" => spread_border_radius(value),
993        _ => style_with(key, value),
994    }
995}
996
997fn transform_unit(container: &Container, value: &StyleValue) -> StyleValue {
998    match value {
999        StyleValue::Number(number) => StyleValue::Number(*number),
1000        StyleValue::String(text) => transform_unit_text(container, text),
1001        _ => value.clone(),
1002    }
1003}
1004
1005fn transform_unit_text(container: &Container, text: &str) -> StyleValue {
1006    let trimmed = text.trim();
1007
1008    if trimmed.ends_with('%') {
1009        return StyleValue::String(trimmed.to_string());
1010    }
1011
1012    let Some((number, unit)) = parse_length_token(trimmed) else {
1013        return StyleValue::String(trimmed.to_string());
1014    };
1015
1016    let dpi = container.dpi.unwrap_or(DEFAULT_DPI);
1017    let value = match unit.as_deref() {
1018        Some("rem") => number * container.rem_base.unwrap_or(DEFAULT_REM_BASE),
1019        Some("in") => number * DEFAULT_DPI,
1020        Some("mm") => number * (DEFAULT_DPI / MM_PER_INCH),
1021        Some("cm") => number * (DEFAULT_DPI / CM_PER_INCH),
1022        Some("vh") => number * (container.height / 100.0),
1023        Some("vw") => number * (container.width / 100.0),
1024        Some("px") => (number * (DEFAULT_DPI / dpi)).round(),
1025        Some("pt") | None => number,
1026        Some(_) => return StyleValue::String(trimmed.to_string()),
1027    };
1028
1029    StyleValue::Number(value)
1030}
1031
1032fn parse_length_token(token: &str) -> Option<(f64, Option<String>)> {
1033    let trimmed = token.trim();
1034    if trimmed.is_empty() {
1035        return None;
1036    }
1037
1038    let mut number_end = 0usize;
1039    for (index, character) in trimmed.char_indices() {
1040        let valid =
1041            character.is_ascii_digit() || character == '.' || (index == 0 && character == '-');
1042        if valid {
1043            number_end = index + character.len_utf8();
1044        } else {
1045            break;
1046        }
1047    }
1048
1049    if number_end == 0 {
1050        return None;
1051    }
1052
1053    let number = trimmed[..number_end].parse::<f64>().ok()?;
1054    let unit = trimmed[number_end..].trim();
1055
1056    if unit.is_empty() {
1057        Some((number, None))
1058    } else {
1059        Some((number, Some(unit.to_string())))
1060    }
1061}
1062
1063fn parse_float_like(text: &str) -> Option<f64> {
1064    let trimmed = text.trim();
1065    let mut end = 0usize;
1066
1067    for (index, character) in trimmed.char_indices() {
1068        let valid =
1069            character.is_ascii_digit() || character == '.' || (index == 0 && character == '-');
1070        if valid {
1071            end = index + character.len_utf8();
1072        } else {
1073            break;
1074        }
1075    }
1076
1077    if end == 0 {
1078        None
1079    } else {
1080        trimmed[..end].parse::<f64>().ok()
1081    }
1082}
1083
1084fn parse_int_like(text: &str) -> Option<i32> {
1085    let trimmed = text.trim();
1086    let mut end = 0usize;
1087
1088    for (index, character) in trimmed.char_indices() {
1089        let valid = character.is_ascii_digit() || (index == 0 && character == '-');
1090        if valid {
1091            end = index + character.len_utf8();
1092        } else {
1093            break;
1094        }
1095    }
1096
1097    if end == 0 {
1098        None
1099    } else {
1100        trimmed[..end].parse::<i32>().ok()
1101    }
1102}
1103
1104fn is_plain_number(text: &str) -> bool {
1105    text.chars().enumerate().all(|(index, character)| {
1106        character.is_ascii_digit() || character == '.' || (index == 0 && character == '-')
1107    })
1108}
1109
1110fn parse_percent(text: &str) -> Option<f64> {
1111    text.strip_suffix('%')
1112        .and_then(parse_float_like)
1113        .map(|percent| percent / 100.0)
1114}
1115
1116fn offset_keyword(value: StyleValue) -> StyleValue {
1117    match value {
1118        StyleValue::String(text) => match text.as_str() {
1119            "top" | "left" => StyleValue::String("0%".to_string()),
1120            "right" | "bottom" => StyleValue::String("100%".to_string()),
1121            "center" => StyleValue::String("50%".to_string()),
1122            _ => StyleValue::String(text),
1123        },
1124        other => other,
1125    }
1126}
1127
1128fn cast_float_value(value: StyleValue) -> StyleValue {
1129    match value {
1130        StyleValue::String(text) if is_plain_number(&text) => {
1131            StyleValue::Number(parse_float_like(&text).unwrap_or(0.0))
1132        }
1133        other => other,
1134    }
1135}
1136
1137fn style_from_pairs(pairs: Vec<(&str, StyleValue)>) -> Style {
1138    pairs
1139        .into_iter()
1140        .map(|(key, value)| (key.to_string(), value))
1141        .collect()
1142}
1143
1144fn matches_media_query(container: &Container, query: &str) -> bool {
1145    let Some(query) = query.strip_prefix("@media") else {
1146        return false;
1147    };
1148
1149    query.split(',').map(str::trim).any(|branch| {
1150        branch
1151            .split(" and ")
1152            .all(|clause| matches_media_clause(container, clause.trim()))
1153    })
1154}
1155
1156fn matches_media_clause(container: &Container, clause: &str) -> bool {
1157    let trimmed = clause.trim().trim_start_matches('(').trim_end_matches(')');
1158    let Some((feature, raw_value)) = trimmed.split_once(':') else {
1159        return false;
1160    };
1161
1162    let feature = feature.trim();
1163    let raw_value = raw_value.trim();
1164
1165    match feature {
1166        "max-height" => {
1167            compare_media_dimension(container.height, raw_value, container, |lhs, rhs| {
1168                lhs <= rhs
1169            })
1170        }
1171        "min-height" => {
1172            compare_media_dimension(container.height, raw_value, container, |lhs, rhs| {
1173                lhs >= rhs
1174            })
1175        }
1176        "max-width" => {
1177            compare_media_dimension(container.width, raw_value, container, |lhs, rhs| lhs <= rhs)
1178        }
1179        "min-width" => {
1180            compare_media_dimension(container.width, raw_value, container, |lhs, rhs| lhs >= rhs)
1181        }
1182        "orientation" => Orientation::from_str(raw_value)
1183            .map(|orientation| orientation == container.resolved_orientation())
1184            .unwrap_or(false),
1185        _ => false,
1186    }
1187}
1188
1189fn compare_media_dimension(
1190    actual: f64,
1191    raw_value: &str,
1192    container: &Container,
1193    predicate: impl Fn(f64, f64) -> bool,
1194) -> bool {
1195    match transform_unit(container, &StyleValue::String(raw_value.to_string())) {
1196        StyleValue::Number(number) => predicate(actual, number),
1197        _ => false,
1198    }
1199}
1200
1201fn parse_rgb_color(value: &str) -> Option<String> {
1202    let lower = value.to_ascii_lowercase();
1203    let has_alpha = lower.starts_with("rgba(");
1204    if !has_alpha && !lower.starts_with("rgb(") {
1205        return None;
1206    }
1207
1208    let start = value.find('(')? + 1;
1209    let end = value.rfind(')')?;
1210    let parts = value[start..end]
1211        .split(',')
1212        .map(str::trim)
1213        .collect::<Vec<_>>();
1214
1215    if (!has_alpha && parts.len() != 3) || (has_alpha && parts.len() != 4) {
1216        return None;
1217    }
1218
1219    let red = clamp_byte(parse_float_like(parts[0])?);
1220    let green = clamp_byte(parse_float_like(parts[1])?);
1221    let blue = clamp_byte(parse_float_like(parts[2])?);
1222    let alpha = if has_alpha {
1223        Some(clamp_alpha(parse_float_like(parts[3])?))
1224    } else {
1225        None
1226    };
1227
1228    Some(rgb_to_hex(red, green, blue, alpha))
1229}
1230
1231fn parse_hsl_color(value: &str) -> Option<String> {
1232    let lower = value.to_ascii_lowercase();
1233    let has_alpha = lower.starts_with("hsla(");
1234    if !has_alpha && !lower.starts_with("hsl(") {
1235        return None;
1236    }
1237
1238    let start = value.find('(')? + 1;
1239    let end = value.rfind(')')?;
1240    let parts = value[start..end]
1241        .split(',')
1242        .map(str::trim)
1243        .collect::<Vec<_>>();
1244
1245    if (!has_alpha && parts.len() != 3) || (has_alpha && parts.len() != 4) {
1246        return None;
1247    }
1248
1249    let hue = parse_float_like(parts[0])?;
1250    let saturation = parse_percent(parts[1])?;
1251    let lightness = parse_percent(parts[2])?;
1252    let alpha = if has_alpha {
1253        Some(clamp_alpha(parse_float_like(parts[3])?))
1254    } else {
1255        None
1256    };
1257
1258    let (red, green, blue) = hsl_to_rgb(hue, saturation, lightness);
1259    Some(rgb_to_hex(red, green, blue, alpha))
1260}
1261
1262fn hsl_to_rgb(hue: f64, saturation: f64, lightness: f64) -> (u8, u8, u8) {
1263    let hue = (hue % 360.0 + 360.0) % 360.0 / 360.0;
1264
1265    if saturation == 0.0 {
1266        let value = clamp_byte(lightness * 255.0);
1267        return (value, value, value);
1268    }
1269
1270    let q = if lightness < 0.5 {
1271        lightness * (1.0 + saturation)
1272    } else {
1273        lightness + saturation - lightness * saturation
1274    };
1275    let p = 2.0 * lightness - q;
1276
1277    (
1278        clamp_byte(hue_to_rgb(p, q, hue + (1.0 / 3.0)) * 255.0),
1279        clamp_byte(hue_to_rgb(p, q, hue) * 255.0),
1280        clamp_byte(hue_to_rgb(p, q, hue - (1.0 / 3.0)) * 255.0),
1281    )
1282}
1283
1284fn hue_to_rgb(p: f64, q: f64, mut t: f64) -> f64 {
1285    if t < 0.0 {
1286        t += 1.0;
1287    }
1288    if t > 1.0 {
1289        t -= 1.0;
1290    }
1291    if t < 1.0 / 6.0 {
1292        return p + (q - p) * 6.0 * t;
1293    }
1294    if t < 1.0 / 2.0 {
1295        return q;
1296    }
1297    if t < 2.0 / 3.0 {
1298        return p + (q - p) * ((2.0 / 3.0) - t) * 6.0;
1299    }
1300    p
1301}
1302
1303fn clamp_byte(value: f64) -> u8 {
1304    value.round().clamp(0.0, 255.0) as u8
1305}
1306
1307fn clamp_alpha(value: f64) -> u8 {
1308    (value.clamp(0.0, 1.0) * 255.0).round() as u8
1309}
1310
1311fn rgb_to_hex(red: u8, green: u8, blue: u8, alpha: Option<u8>) -> String {
1312    match alpha {
1313        Some(alpha) if alpha < 255 => format!("#{red:02X}{green:02X}{blue:02X}{alpha:02X}"),
1314        _ => format!("#{red:02X}{green:02X}{blue:02X}"),
1315    }
1316}
1317
1318#[cfg(test)]
1319mod tests {
1320    use super::*;
1321
1322    fn container() -> Container {
1323        Container {
1324            width: 200.0,
1325            height: 400.0,
1326            dpi: None,
1327            rem_base: Some(10.0),
1328            orientation: None,
1329        }
1330    }
1331
1332    fn style(entries: Vec<(&str, StyleValue)>) -> Style {
1333        entries
1334            .into_iter()
1335            .map(|(key, value)| (key.to_string(), value))
1336            .collect()
1337    }
1338
1339    fn media(entries: Vec<(&str, StyleValue)>) -> StyleValue {
1340        StyleValue::Object(style(entries))
1341    }
1342
1343    #[test]
1344    fn flattens_nested_styles_and_ignores_nullish_entries() {
1345        let input = StyleValue::Array(vec![
1346            StyleValue::Null,
1347            StyleValue::Object(style(vec![("backgroundColor", "black".into())])),
1348            false.into(),
1349            StyleValue::Array(vec![StyleValue::Object(style(vec![
1350                ("color", "red".into()),
1351                ("textAlign", "center".into()),
1352            ]))]),
1353        ]);
1354
1355        let flattened = flatten(&input);
1356
1357        assert_eq!(
1358            flattened,
1359            style(vec![
1360                ("backgroundColor", "black".into()),
1361                ("color", "red".into()),
1362                ("textAlign", "center".into()),
1363            ])
1364        );
1365    }
1366
1367    #[test]
1368    fn resolves_media_queries_with_and_or_and_ordered_overrides() {
1369        let container = Container {
1370            width: 400.0,
1371            height: 300.0,
1372            dpi: None,
1373            rem_base: None,
1374            orientation: Some(Orientation::Landscape),
1375        };
1376        let styles = style(vec![
1377            ("color", "black".into()),
1378            (
1379                "@media min-width: 300 and max-width: 500",
1380                media(vec![("color", "red".into())]),
1381            ),
1382            (
1383                "@media max-width: 300, orientation: landscape",
1384                media(vec![("backgroundColor", "blue".into())]),
1385            ),
1386            (
1387                "@media max-height: 400",
1388                media(vec![("color", "green".into())]),
1389            ),
1390        ]);
1391
1392        let resolved = resolve_media_queries(&container, &styles);
1393
1394        assert_eq!(
1395            resolved,
1396            style(vec![
1397                ("color", "green".into()),
1398                ("backgroundColor", "blue".into()),
1399            ])
1400        );
1401    }
1402
1403    #[test]
1404    fn resolves_margins_and_preserves_auto_and_percent_values() {
1405        let resolved = resolve_style(
1406            &container(),
1407            &style(vec![("margin", "auto 20 30 40%".into())]),
1408        );
1409
1410        assert_eq!(
1411            resolved,
1412            style(vec![
1413                ("marginTop", "auto".into()),
1414                ("marginRight", 20.into()),
1415                ("marginBottom", 30.into()),
1416                ("marginLeft", "40%".into()),
1417            ])
1418        );
1419    }
1420
1421    #[test]
1422    fn ignores_invalid_padding_syntax() {
1423        let resolved = resolve_style(
1424            &container(),
1425            &style(vec![("padding", "calc(100% - 10px)".into())]),
1426        );
1427
1428        assert!(resolved.is_empty());
1429    }
1430
1431    #[test]
1432    fn resolves_border_shorthand_and_color_conversion() {
1433        let resolved = resolve_style(
1434            &container(),
1435            &style(vec![("border", "1in solid rgba(0, 255, 0, 0.5)".into())]),
1436        );
1437
1438        assert_eq!(
1439            resolved,
1440            style(vec![
1441                ("borderTopColor", "#00FF0080".into()),
1442                ("borderTopStyle", "solid".into()),
1443                ("borderTopWidth", 72.into()),
1444                ("borderRightColor", "#00FF0080".into()),
1445                ("borderRightStyle", "solid".into()),
1446                ("borderRightWidth", 72.into()),
1447                ("borderBottomColor", "#00FF0080".into()),
1448                ("borderBottomStyle", "solid".into()),
1449                ("borderBottomWidth", 72.into()),
1450                ("borderLeftColor", "#00FF0080".into()),
1451                ("borderLeftStyle", "solid".into()),
1452                ("borderLeftWidth", 72.into()),
1453            ])
1454        );
1455    }
1456
1457    #[test]
1458    fn resolves_gap_and_flex_shorthands() {
1459        let resolved = resolve_style(
1460            &container(),
1461            &style(vec![
1462                ("gap", "10px 20%".into()),
1463                ("flex", "2 3 1rem".into()),
1464            ]),
1465        );
1466
1467        assert_eq!(resolved.get("rowGap"), Some(&10.into()));
1468        assert_eq!(resolved.get("columnGap"), Some(&"20%".into()));
1469        assert_eq!(resolved.get("flexGrow"), Some(&2.into()));
1470        assert_eq!(resolved.get("flexShrink"), Some(&3.into()));
1471        assert_eq!(resolved.get("flexBasis"), Some(&10.into()));
1472    }
1473
1474    #[test]
1475    fn resolves_object_position_keywords_and_lengths() {
1476        let resolved = resolve_style(
1477            &container(),
1478            &style(vec![("objectPosition", "left 2rem".into())]),
1479        );
1480
1481        assert_eq!(
1482            resolved,
1483            style(vec![
1484                ("objectPositionX", "0%".into()),
1485                ("objectPositionY", 20.into())
1486            ])
1487        );
1488    }
1489
1490    #[test]
1491    fn resolves_text_handlers_and_color_transforms() {
1492        let resolved = resolve_style(
1493            &container(),
1494            &style(vec![
1495                ("fontWeight", "semibold".into()),
1496                ("fontSize", "2rem".into()),
1497                ("lineHeight", "150%".into()),
1498                ("textDecorationColor", "hsl(0, 100%, 50%)".into()),
1499            ]),
1500        );
1501
1502        assert_eq!(resolved.get("fontWeight"), Some(&600.into()));
1503        assert_eq!(resolved.get("fontSize"), Some(&20.into()));
1504        assert_eq!(resolved.get("lineHeight"), Some(&30.into()));
1505        assert_eq!(resolved.get("textDecorationColor"), Some(&"#FF0000".into()));
1506    }
1507
1508    #[test]
1509    fn resolves_transform_origin_and_transform_operations() {
1510        let resolved = resolve_style(
1511            &container(),
1512            &style(vec![
1513                ("transformOrigin", "top left".into()),
1514                (
1515                    "transform",
1516                    "translate(10px, 20px) rotate(90deg) skewX(30deg) matrix(1, 0, 0, 1, 5, 10)"
1517                        .into(),
1518                ),
1519            ]),
1520        );
1521
1522        assert_eq!(resolved.get("transformOriginX"), Some(&"0%".into()));
1523        assert_eq!(resolved.get("transformOriginY"), Some(&"0%".into()));
1524
1525        let StyleValue::Array(operations) = resolved.get("transform").cloned().unwrap() else {
1526            panic!("expected parsed transform array");
1527        };
1528
1529        assert_eq!(operations.len(), 4);
1530        assert_eq!(
1531            operations[0],
1532            StyleValue::Object(style(vec![
1533                ("operation", "translate".into()),
1534                ("value", StyleValue::Array(vec![10.into(), 20.into()])),
1535            ]))
1536        );
1537        assert_eq!(
1538            operations[1],
1539            StyleValue::Object(style(vec![
1540                ("operation", "rotate".into()),
1541                (
1542                    "value",
1543                    StyleValue::Array(vec![90.into(), 0.into(), 0.into()])
1544                ),
1545            ]))
1546        );
1547    }
1548
1549    #[test]
1550    fn resolves_svg_handlers() {
1551        let resolved = resolve_style(
1552            &container(),
1553            &style(vec![
1554                ("fill", "rgb(255, 0, 255)".into()),
1555                ("strokeWidth", "2rem".into()),
1556                ("fillOpacity", "0.5".into()),
1557            ]),
1558        );
1559
1560        assert_eq!(resolved.get("fill"), Some(&"#FF00FF".into()));
1561        assert_eq!(resolved.get("strokeWidth"), Some(&20.into()));
1562        assert_eq!(resolved.get("fillOpacity"), Some(&0.5.into()));
1563    }
1564
1565    #[test]
1566    fn resolves_end_to_end_style_pipeline() {
1567        let input = StyleValue::Array(vec![
1568            StyleValue::Object(style(vec![("margin", "10px".into())])),
1569            StyleValue::Object(style(vec![
1570                ("padding", "2rem".into()),
1571                ("width", "1in".into()),
1572                (
1573                    "@media min-width: 100",
1574                    media(vec![("backgroundColor", "rgb(255, 0, 0)".into())]),
1575                ),
1576            ])),
1577        ]);
1578
1579        let resolved = resolve_styles(&container(), &input);
1580
1581        assert_eq!(resolved.get("marginTop"), Some(&10.into()));
1582        assert_eq!(resolved.get("marginRight"), Some(&10.into()));
1583        assert_eq!(resolved.get("paddingLeft"), Some(&20.into()));
1584        assert_eq!(resolved.get("paddingBottom"), Some(&20.into()));
1585        assert_eq!(resolved.get("width"), Some(&72.into()));
1586        assert_eq!(resolved.get("backgroundColor"), Some(&"#FF0000".into()));
1587    }
1588
1589    #[test]
1590    fn transforms_rgb_and_hsl_colors() {
1591        assert_eq!(transform_color("rgb(255, 0, 0)"), "#FF0000");
1592        assert_eq!(transform_color("rgba(0, 255, 0, 0.5)"), "#00FF0080");
1593        assert_eq!(transform_color("hsl(0, 100%, 50%)"), "#FF0000");
1594        assert_eq!(transform_color("hsla(0, 100%, 50%, 0.5)"), "#FF000080");
1595    }
1596
1597    #[test]
1598    fn stylesheet_wrapper_resolves_input() {
1599        let stylesheet = Stylesheet::new(StyleValue::Object(style(vec![("width", "2rem".into())])));
1600        let resolved = stylesheet.resolve(&container());
1601
1602        assert!(!stylesheet.is_empty());
1603        assert_eq!(resolved.get("width"), Some(&20.into()));
1604    }
1605}