1use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6pub type StyleMap = HashMap<String, Style>;
8
9#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "kebab-case")]
16pub enum WritingMode {
17 #[default]
20 HorizontalTb,
21
22 VerticalRl,
25
26 VerticalLr,
29
30 SidewaysRl,
32
33 SidewaysLr,
35}
36
37#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
42#[serde(rename_all = "camelCase")]
43pub struct Transform {
44 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub rotate: Option<String>,
47
48 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub scale: Option<Scale>,
51
52 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub skew_x: Option<String>,
55
56 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub skew_y: Option<String>,
59
60 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub translate_x: Option<String>,
63
64 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub translate_y: Option<String>,
67
68 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub matrix: Option<[f64; 6]>,
71
72 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub origin: Option<TransformOrigin>,
75}
76
77impl Transform {
78 #[must_use]
80 pub fn rotate(angle: impl Into<String>) -> Self {
81 Self {
82 rotate: Some(angle.into()),
83 ..Default::default()
84 }
85 }
86
87 #[must_use]
89 pub fn scale_uniform(factor: f64) -> Self {
90 Self {
91 scale: Some(Scale::Uniform(factor)),
92 ..Default::default()
93 }
94 }
95
96 #[must_use]
98 pub fn scale_xy(x: f64, y: f64) -> Self {
99 Self {
100 scale: Some(Scale::NonUniform { x, y }),
101 ..Default::default()
102 }
103 }
104
105 #[must_use]
107 pub fn translate(x: impl Into<String>, y: impl Into<String>) -> Self {
108 Self {
109 translate_x: Some(x.into()),
110 translate_y: Some(y.into()),
111 ..Default::default()
112 }
113 }
114
115 #[must_use]
117 pub fn with_origin(mut self, origin: TransformOrigin) -> Self {
118 self.origin = Some(origin);
119 self
120 }
121}
122
123#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
125#[serde(untagged)]
126pub enum Scale {
127 Uniform(f64),
129 NonUniform {
131 x: f64,
133 y: f64,
135 },
136}
137
138#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
140#[serde(untagged)]
141pub enum TransformOrigin {
142 Keyword(String),
144 Point {
146 x: String,
148 y: String,
150 },
151}
152
153impl TransformOrigin {
154 #[must_use]
156 pub fn center() -> Self {
157 Self::Keyword("center".to_string())
158 }
159
160 #[must_use]
162 pub fn top_left() -> Self {
163 Self::Keyword("top left".to_string())
164 }
165
166 #[must_use]
168 pub fn point(x: impl Into<String>, y: impl Into<String>) -> Self {
169 Self::Point {
170 x: x.into(),
171 y: y.into(),
172 }
173 }
174}
175
176#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
178#[serde(rename_all = "camelCase")]
179pub struct Style {
180 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub font_family: Option<String>,
184
185 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub font_size: Option<CssValue>,
188
189 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub font_weight: Option<FontWeight>,
192
193 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub font_style: Option<String>,
196
197 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub line_height: Option<CssValue>,
200
201 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub letter_spacing: Option<CssValue>,
204
205 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub text_align: Option<TextAlign>,
208
209 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub text_decoration: Option<String>,
212
213 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub text_transform: Option<String>,
216
217 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub color: Option<Color>,
220
221 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub margin_top: Option<CssValue>,
225
226 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub margin_right: Option<CssValue>,
229
230 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub margin_bottom: Option<CssValue>,
233
234 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub margin_left: Option<CssValue>,
237
238 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub padding_top: Option<CssValue>,
241
242 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub padding_right: Option<CssValue>,
245
246 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub padding_bottom: Option<CssValue>,
249
250 #[serde(default, skip_serializing_if = "Option::is_none")]
252 pub padding_left: Option<CssValue>,
253
254 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub border_width: Option<CssValue>,
258
259 #[serde(default, skip_serializing_if = "Option::is_none")]
261 pub border_style: Option<String>,
262
263 #[serde(default, skip_serializing_if = "Option::is_none")]
265 pub border_color: Option<Color>,
266
267 #[serde(default, skip_serializing_if = "Option::is_none")]
270 pub background_color: Option<Color>,
271
272 #[serde(default, skip_serializing_if = "Option::is_none")]
275 pub width: Option<CssValue>,
276
277 #[serde(default, skip_serializing_if = "Option::is_none")]
279 pub height: Option<CssValue>,
280
281 #[serde(default, skip_serializing_if = "Option::is_none")]
283 pub max_width: Option<CssValue>,
284
285 #[serde(default, skip_serializing_if = "Option::is_none")]
287 pub max_height: Option<CssValue>,
288
289 #[serde(default, skip_serializing_if = "Option::is_none")]
292 pub page_break_before: Option<String>,
293
294 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub page_break_after: Option<String>,
297
298 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub extends: Option<String>,
302
303 #[serde(default, skip_serializing_if = "Option::is_none")]
306 pub writing_mode: Option<WritingMode>,
307
308 #[serde(default, skip_serializing_if = "Option::is_none")]
311 pub z_index: Option<i32>,
312
313 #[serde(default, skip_serializing_if = "Option::is_none")]
316 pub background_image: Option<String>,
317
318 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub background_size: Option<String>,
321
322 #[serde(default, skip_serializing_if = "Option::is_none")]
324 pub background_position: Option<String>,
325
326 #[serde(default, skip_serializing_if = "Option::is_none")]
328 pub background_repeat: Option<String>,
329
330 #[serde(default, skip_serializing_if = "Option::is_none")]
333 pub opacity: Option<f32>,
334
335 #[serde(default, skip_serializing_if = "Option::is_none")]
337 pub border_radius: Option<CssValue>,
338
339 #[serde(default, skip_serializing_if = "Option::is_none")]
341 pub box_shadow: Option<String>,
342}
343
344#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
346#[serde(untagged)]
347pub enum CssValue {
348 Number(f32),
350 String(String),
352}
353
354impl CssValue {
355 #[must_use]
357 pub fn px(value: f32) -> Self {
358 Self::String(format!("{value}px"))
359 }
360
361 #[must_use]
363 pub fn pt(value: f32) -> Self {
364 Self::String(format!("{value}pt"))
365 }
366
367 #[must_use]
369 pub fn em(value: f32) -> Self {
370 Self::String(format!("{value}em"))
371 }
372
373 #[must_use]
375 pub fn rem(value: f32) -> Self {
376 Self::String(format!("{value}rem"))
377 }
378
379 #[must_use]
381 pub fn percent(value: f32) -> Self {
382 Self::String(format!("{value}%"))
383 }
384
385 #[must_use]
387 pub fn inch(value: f32) -> Self {
388 Self::String(format!("{value}in"))
389 }
390}
391
392impl From<f32> for CssValue {
393 fn from(value: f32) -> Self {
394 Self::Number(value)
395 }
396}
397
398impl From<&str> for CssValue {
399 fn from(value: &str) -> Self {
400 Self::String(value.to_string())
401 }
402}
403
404#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
406#[serde(untagged)]
407pub enum FontWeight {
408 Number(u16),
410 Keyword(String),
412}
413
414impl FontWeight {
415 #[must_use]
417 pub fn normal() -> Self {
418 Self::Number(400)
419 }
420
421 #[must_use]
423 pub fn bold() -> Self {
424 Self::Number(700)
425 }
426}
427
428#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
430#[serde(rename_all = "lowercase")]
431pub enum TextAlign {
432 Left,
434 Center,
436 Right,
438 Justify,
440}
441
442#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
444#[serde(untagged)]
445pub enum Color {
446 Named(String),
448}
449
450impl Color {
451 #[must_use]
453 pub fn hex(value: impl Into<String>) -> Self {
454 Self::Named(value.into())
455 }
456
457 #[must_use]
459 pub fn black() -> Self {
460 Self::Named("black".to_string())
461 }
462
463 #[must_use]
465 pub fn white() -> Self {
466 Self::Named("white".to_string())
467 }
468}
469
470impl From<&str> for Color {
471 fn from(value: &str) -> Self {
472 Self::Named(value.to_string())
473 }
474}
475
476#[cfg(test)]
477mod tests {
478 use super::*;
479
480 #[test]
481 fn test_style_default() {
482 let style = Style::default();
483 assert!(style.font_family.is_none());
484 assert!(style.font_size.is_none());
485 }
486
487 #[test]
488 fn test_css_value_units() {
489 assert!(matches!(CssValue::px(16.0), CssValue::String(s) if s == "16px"));
490 assert!(matches!(CssValue::em(1.5), CssValue::String(s) if s == "1.5em"));
491 assert!(matches!(CssValue::percent(100.0), CssValue::String(s) if s == "100%"));
492 }
493
494 #[test]
495 fn test_style_serialization() {
496 let style = Style {
497 font_family: Some("Georgia, serif".to_string()),
498 font_size: Some(CssValue::px(16.0)),
499 font_weight: Some(FontWeight::bold()),
500 color: Some(Color::hex("#333")),
501 ..Default::default()
502 };
503
504 let json = serde_json::to_string_pretty(&style).unwrap();
505 assert!(json.contains("\"fontFamily\": \"Georgia, serif\""));
506 assert!(json.contains("\"fontSize\": \"16px\""));
507 assert!(json.contains("\"fontWeight\": 700"));
508 }
509
510 #[test]
511 fn test_style_deserialization() {
512 let json = r##"{
513 "fontFamily": "system-ui, sans-serif",
514 "fontSize": "1rem",
515 "lineHeight": 1.6,
516 "marginBottom": "1em",
517 "color": "#333333"
518 }"##;
519
520 let style: Style = serde_json::from_str(json).unwrap();
521 assert_eq!(style.font_family, Some("system-ui, sans-serif".to_string()));
522 assert!(matches!(style.line_height, Some(CssValue::Number(n)) if (n - 1.6).abs() < 0.001));
523 }
524
525 #[test]
526 fn test_writing_mode_serialization() {
527 let mode = WritingMode::VerticalRl;
528 let json = serde_json::to_string(&mode).unwrap();
529 assert_eq!(json, "\"vertical-rl\"");
530
531 let mode = WritingMode::HorizontalTb;
532 let json = serde_json::to_string(&mode).unwrap();
533 assert_eq!(json, "\"horizontal-tb\"");
534 }
535
536 #[test]
537 fn test_writing_mode_deserialization() {
538 let mode: WritingMode = serde_json::from_str("\"vertical-lr\"").unwrap();
539 assert_eq!(mode, WritingMode::VerticalLr);
540
541 let mode: WritingMode = serde_json::from_str("\"sideways-rl\"").unwrap();
542 assert_eq!(mode, WritingMode::SidewaysRl);
543 }
544
545 #[test]
546 fn test_transform_rotate() {
547 let t = Transform::rotate("45deg");
548 assert_eq!(t.rotate, Some("45deg".to_string()));
549 assert!(t.scale.is_none());
550 }
551
552 #[test]
553 fn test_transform_scale_uniform() {
554 let t = Transform::scale_uniform(2.0);
555 assert!(matches!(t.scale, Some(Scale::Uniform(s)) if (s - 2.0).abs() < 0.001));
556 }
557
558 #[test]
559 fn test_transform_scale_xy() {
560 let t = Transform::scale_xy(1.5, 2.0);
561 if let Some(Scale::NonUniform { x, y }) = t.scale {
562 assert!((x - 1.5).abs() < 0.001);
563 assert!((y - 2.0).abs() < 0.001);
564 } else {
565 panic!("Expected NonUniform scale");
566 }
567 }
568
569 #[test]
570 fn test_transform_translate() {
571 let t = Transform::translate("10px", "20px");
572 assert_eq!(t.translate_x, Some("10px".to_string()));
573 assert_eq!(t.translate_y, Some("20px".to_string()));
574 }
575
576 #[test]
577 fn test_transform_origin() {
578 let t = Transform::rotate("90deg").with_origin(TransformOrigin::center());
579 assert!(matches!(t.origin, Some(TransformOrigin::Keyword(ref k)) if k == "center"));
580 }
581
582 #[test]
583 fn test_transform_serialization() {
584 let t = Transform {
585 rotate: Some("45deg".to_string()),
586 scale: Some(Scale::Uniform(1.5)),
587 origin: Some(TransformOrigin::center()),
588 ..Default::default()
589 };
590 let json = serde_json::to_string(&t).unwrap();
591 assert!(json.contains("\"rotate\":\"45deg\""));
592 assert!(json.contains("\"scale\":1.5"));
593 assert!(json.contains("\"origin\":\"center\""));
594 }
595
596 #[test]
597 fn test_style_with_new_properties() {
598 let style = Style {
599 writing_mode: Some(WritingMode::VerticalRl),
600 z_index: Some(10),
601 opacity: Some(0.8),
602 border_radius: Some(CssValue::px(8.0)),
603 background_image: Some("url('bg.png')".to_string()),
604 ..Default::default()
605 };
606
607 let json = serde_json::to_string_pretty(&style).unwrap();
608 assert!(json.contains("\"writingMode\": \"vertical-rl\""));
609 assert!(json.contains("\"zIndex\": 10"));
610 assert!(json.contains("\"opacity\": 0.8"));
611 assert!(json.contains("\"borderRadius\": \"8px\""));
612 assert!(json.contains("\"backgroundImage\": \"url('bg.png')\""));
613 }
614}