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}