use serde::{Deserialize, Serialize};
use std::collections::HashMap;
pub type StyleMap = HashMap<String, Style>;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum WritingMode {
#[default]
HorizontalTb,
VerticalRl,
VerticalLr,
SidewaysRl,
SidewaysLr,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Transform {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rotate: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scale: Option<Scale>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skew_x: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skew_y: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub translate_x: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub translate_y: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub matrix: Option<[f64; 6]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub origin: Option<TransformOrigin>,
}
impl Transform {
#[must_use]
pub fn rotate(angle: impl Into<String>) -> Self {
Self {
rotate: Some(angle.into()),
..Default::default()
}
}
#[must_use]
pub fn scale_uniform(factor: f64) -> Self {
Self {
scale: Some(Scale::Uniform(factor)),
..Default::default()
}
}
#[must_use]
pub fn scale_xy(x: f64, y: f64) -> Self {
Self {
scale: Some(Scale::NonUniform { x, y }),
..Default::default()
}
}
#[must_use]
pub fn translate(x: impl Into<String>, y: impl Into<String>) -> Self {
Self {
translate_x: Some(x.into()),
translate_y: Some(y.into()),
..Default::default()
}
}
#[must_use]
pub fn with_origin(mut self, origin: TransformOrigin) -> Self {
self.origin = Some(origin);
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Scale {
Uniform(f64),
NonUniform {
x: f64,
y: f64,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum TransformOrigin {
Keyword(String),
Point {
x: String,
y: String,
},
}
impl TransformOrigin {
#[must_use]
pub fn center() -> Self {
Self::Keyword("center".to_string())
}
#[must_use]
pub fn top_left() -> Self {
Self::Keyword("top left".to_string())
}
#[must_use]
pub fn point(x: impl Into<String>, y: impl Into<String>) -> Self {
Self::Point {
x: x.into(),
y: y.into(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Style {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub font_family: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub font_size: Option<CssValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub font_weight: Option<FontWeight>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub font_style: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub line_height: Option<CssValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub letter_spacing: Option<CssValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text_align: Option<TextAlign>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text_decoration: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text_transform: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<Color>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub margin_top: Option<CssValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub margin_right: Option<CssValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub margin_bottom: Option<CssValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub margin_left: Option<CssValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub padding_top: Option<CssValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub padding_right: Option<CssValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub padding_bottom: Option<CssValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub padding_left: Option<CssValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub border_width: Option<CssValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub border_style: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub border_color: Option<Color>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub background_color: Option<Color>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub width: Option<CssValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub height: Option<CssValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_width: Option<CssValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_height: Option<CssValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub page_break_before: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub page_break_after: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extends: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub writing_mode: Option<WritingMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub z_index: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub background_image: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub background_size: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub background_position: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub background_repeat: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opacity: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub border_radius: Option<CssValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub box_shadow: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum CssValue {
Number(f32),
String(String),
}
impl CssValue {
#[must_use]
pub fn px(value: f32) -> Self {
Self::String(format!("{value}px"))
}
#[must_use]
pub fn pt(value: f32) -> Self {
Self::String(format!("{value}pt"))
}
#[must_use]
pub fn em(value: f32) -> Self {
Self::String(format!("{value}em"))
}
#[must_use]
pub fn rem(value: f32) -> Self {
Self::String(format!("{value}rem"))
}
#[must_use]
pub fn percent(value: f32) -> Self {
Self::String(format!("{value}%"))
}
#[must_use]
pub fn inch(value: f32) -> Self {
Self::String(format!("{value}in"))
}
}
impl From<f32> for CssValue {
fn from(value: f32) -> Self {
Self::Number(value)
}
}
impl From<&str> for CssValue {
fn from(value: &str) -> Self {
Self::String(value.to_string())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum FontWeight {
Number(u16),
Keyword(String),
}
impl FontWeight {
#[must_use]
pub fn normal() -> Self {
Self::Number(400)
}
#[must_use]
pub fn bold() -> Self {
Self::Number(700)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TextAlign {
Left,
Center,
Right,
Justify,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Color {
Named(String),
}
impl Color {
#[must_use]
pub fn hex(value: impl Into<String>) -> Self {
Self::Named(value.into())
}
#[must_use]
pub fn black() -> Self {
Self::Named("black".to_string())
}
#[must_use]
pub fn white() -> Self {
Self::Named("white".to_string())
}
}
impl From<&str> for Color {
fn from(value: &str) -> Self {
Self::Named(value.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_style_default() {
let style = Style::default();
assert!(style.font_family.is_none());
assert!(style.font_size.is_none());
}
#[test]
fn test_css_value_units() {
assert!(matches!(CssValue::px(16.0), CssValue::String(s) if s == "16px"));
assert!(matches!(CssValue::em(1.5), CssValue::String(s) if s == "1.5em"));
assert!(matches!(CssValue::percent(100.0), CssValue::String(s) if s == "100%"));
}
#[test]
fn test_style_serialization() {
let style = Style {
font_family: Some("Georgia, serif".to_string()),
font_size: Some(CssValue::px(16.0)),
font_weight: Some(FontWeight::bold()),
color: Some(Color::hex("#333")),
..Default::default()
};
let json = serde_json::to_string_pretty(&style).unwrap();
assert!(json.contains("\"fontFamily\": \"Georgia, serif\""));
assert!(json.contains("\"fontSize\": \"16px\""));
assert!(json.contains("\"fontWeight\": 700"));
}
#[test]
fn test_style_deserialization() {
let json = r##"{
"fontFamily": "system-ui, sans-serif",
"fontSize": "1rem",
"lineHeight": 1.6,
"marginBottom": "1em",
"color": "#333333"
}"##;
let style: Style = serde_json::from_str(json).unwrap();
assert_eq!(style.font_family, Some("system-ui, sans-serif".to_string()));
assert!(matches!(style.line_height, Some(CssValue::Number(n)) if (n - 1.6).abs() < 0.001));
}
#[test]
fn test_writing_mode_serialization() {
let mode = WritingMode::VerticalRl;
let json = serde_json::to_string(&mode).unwrap();
assert_eq!(json, "\"vertical-rl\"");
let mode = WritingMode::HorizontalTb;
let json = serde_json::to_string(&mode).unwrap();
assert_eq!(json, "\"horizontal-tb\"");
}
#[test]
fn test_writing_mode_deserialization() {
let mode: WritingMode = serde_json::from_str("\"vertical-lr\"").unwrap();
assert_eq!(mode, WritingMode::VerticalLr);
let mode: WritingMode = serde_json::from_str("\"sideways-rl\"").unwrap();
assert_eq!(mode, WritingMode::SidewaysRl);
}
#[test]
fn test_transform_rotate() {
let t = Transform::rotate("45deg");
assert_eq!(t.rotate, Some("45deg".to_string()));
assert!(t.scale.is_none());
}
#[test]
fn test_transform_scale_uniform() {
let t = Transform::scale_uniform(2.0);
assert!(matches!(t.scale, Some(Scale::Uniform(s)) if (s - 2.0).abs() < 0.001));
}
#[test]
fn test_transform_scale_xy() {
let t = Transform::scale_xy(1.5, 2.0);
if let Some(Scale::NonUniform { x, y }) = t.scale {
assert!((x - 1.5).abs() < 0.001);
assert!((y - 2.0).abs() < 0.001);
} else {
panic!("Expected NonUniform scale");
}
}
#[test]
fn test_transform_translate() {
let t = Transform::translate("10px", "20px");
assert_eq!(t.translate_x, Some("10px".to_string()));
assert_eq!(t.translate_y, Some("20px".to_string()));
}
#[test]
fn test_transform_origin() {
let t = Transform::rotate("90deg").with_origin(TransformOrigin::center());
assert!(matches!(t.origin, Some(TransformOrigin::Keyword(ref k)) if k == "center"));
}
#[test]
fn test_transform_serialization() {
let t = Transform {
rotate: Some("45deg".to_string()),
scale: Some(Scale::Uniform(1.5)),
origin: Some(TransformOrigin::center()),
..Default::default()
};
let json = serde_json::to_string(&t).unwrap();
assert!(json.contains("\"rotate\":\"45deg\""));
assert!(json.contains("\"scale\":1.5"));
assert!(json.contains("\"origin\":\"center\""));
}
#[test]
fn test_style_with_new_properties() {
let style = Style {
writing_mode: Some(WritingMode::VerticalRl),
z_index: Some(10),
opacity: Some(0.8),
border_radius: Some(CssValue::px(8.0)),
background_image: Some("url('bg.png')".to_string()),
..Default::default()
};
let json = serde_json::to_string_pretty(&style).unwrap();
assert!(json.contains("\"writingMode\": \"vertical-rl\""));
assert!(json.contains("\"zIndex\": 10"));
assert!(json.contains("\"opacity\": 0.8"));
assert!(json.contains("\"borderRadius\": \"8px\""));
assert!(json.contains("\"backgroundImage\": \"url('bg.png')\""));
}
}