use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use super::units::{Length, LengthContext, LengthPercentage, ParsedLength};
use crate::schema::{deserialize_animation_effects, AnimationEffect, GradientBorder, InnerShadow};
pub const MIN_LEGIBLE_FONT_RATIO: f32 = 0.012;
pub const TEXT_AUTOFIT_MIN_FONT_PX: f32 = MIN_LEGIBLE_FONT_RATIO * 1080.0;
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
pub struct CssStyle {
pub display: Option<Display>,
pub position: Option<Position>,
pub top: Option<LengthPercentage>,
pub right: Option<LengthPercentage>,
pub bottom: Option<LengthPercentage>,
pub left: Option<LengthPercentage>,
pub width: Option<Size>,
pub height: Option<Size>,
pub min_width: Option<Size>,
pub min_height: Option<Size>,
pub max_width: Option<Size>,
pub max_height: Option<Size>,
pub margin: Option<Edges>,
pub padding: Option<Edges>,
pub border: Option<BorderEdges>,
pub box_sizing: Option<BoxSizing>,
pub aspect_ratio: Option<f32>,
pub flex_direction: Option<FlexDirection>,
pub flex_wrap: Option<FlexWrap>,
pub justify_content: Option<JustifyContent>,
pub align_items: Option<AlignItems>,
pub align_self: Option<AlignSelf>,
pub align_content: Option<AlignContent>,
pub gap: Option<Gap>,
pub flex_grow: Option<f32>,
pub flex_shrink: Option<f32>,
pub flex_basis: Option<Size>,
pub order: Option<i32>,
pub grid_template_columns: Option<Vec<GridTrack>>,
pub grid_template_rows: Option<Vec<GridTrack>>,
pub grid_column: Option<GridLine>,
pub grid_row: Option<GridLine>,
pub grid_auto_flow: Option<GridAutoFlow>,
pub justify_items: Option<JustifyItems>,
pub justify_self: Option<JustifySelf>,
pub font_family: Option<String>,
pub font_size: Option<Length>,
pub font_weight: Option<FontWeight>,
pub font_style: Option<FontStyle>,
pub line_height: Option<LineHeight>,
pub letter_spacing: Option<Length>,
pub text_align: Option<TextAlign>,
pub color: Option<Color>,
pub white_space: Option<WhiteSpace>,
pub overflow_wrap: Option<OverflowWrap>,
pub text_overflow: Option<TextOverflow>,
pub text_decoration: Option<TextDecoration>,
pub text_autofit: Option<bool>,
pub background: Option<Background>,
pub border_radius: Option<BorderRadius>,
pub box_shadow: Option<Vec<BoxShadow>>,
pub text_shadow: Option<Vec<TextShadow>>,
pub opacity: Option<f32>,
pub mix_blend_mode: Option<BlendMode>,
pub clip_path: Option<ClipPath>,
pub gradient_border: Option<GradientBorder>,
pub backdrop_blur: Option<f32>,
pub inner_shadow: Option<InnerShadow>,
pub filter: Option<Vec<FilterFn>>,
pub backdrop_filter: Option<Vec<FilterFn>>,
pub transform: Option<Vec<TransformFn>>,
pub transform_origin: Option<TransformOrigin>,
pub perspective: Option<Length>,
pub perspective_origin: Option<TransformOrigin>,
pub depth: Option<f32>,
pub overflow: Option<Overflow>,
pub overflow_x: Option<Overflow>,
pub overflow_y: Option<Overflow>,
pub z_index: Option<i32>,
pub visibility: Option<Visibility>,
#[serde(default, deserialize_with = "deserialize_animation_effects")]
pub animation: Vec<AnimationEffect>,
pub transition: Option<StyleTransition>,
#[serde(default)]
pub audio_reactive: Option<AudioReactive>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum StyleTransition {
Duration(f64),
Config {
duration: f64,
#[serde(default = "default_transition_easing")]
easing: crate::schema::EasingType,
},
}
fn default_transition_easing() -> crate::schema::EasingType {
crate::schema::EasingType::EaseInOut
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct AudioReactive {
#[serde(default)]
pub track: Option<String>,
pub source: AudioSource,
pub property: AudioReactiveProperty,
pub min: f64,
pub max: f64,
#[serde(default)]
pub smoothing_frames: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum AudioSource {
Amplitude(AudioSourceTag),
Band { band: u8 },
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum AudioSourceTag {
Amplitude,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum AudioReactiveProperty {
Opacity,
Scale,
TranslateY,
Rotation,
}
impl StyleTransition {
pub fn duration(&self) -> f64 {
match self {
StyleTransition::Duration(d) => *d,
StyleTransition::Config { duration, .. } => *duration,
}
}
pub fn easing(&self) -> crate::schema::EasingType {
match self {
StyleTransition::Duration(_) => default_transition_easing(),
StyleTransition::Config { easing, .. } => easing.clone(),
}
}
}
impl CssStyle {
pub fn font_size_px_or(&self, default: f32) -> f32 {
self.font_size.as_ref().map(|l| l.px()).unwrap_or(default)
}
pub fn font_size_px(&self) -> Option<f32> {
self.font_size.as_ref().map(|l| l.px())
}
pub fn color_str(&self) -> Option<&str> {
match &self.color {
Some(Color::String(s)) => Some(s.as_str()),
_ => None,
}
}
pub fn color_str_or<'a>(&'a self, default: &'a str) -> &'a str {
self.color_str().unwrap_or(default)
}
pub fn font_family_str(&self) -> Option<&str> {
self.font_family.as_deref()
}
pub fn font_family_or<'a>(&'a self, default: &'a str) -> &'a str {
self.font_family.as_deref().unwrap_or(default)
}
pub fn letter_spacing_px(&self) -> f32 {
self.letter_spacing.as_ref().map(|l| l.px()).unwrap_or(0.0)
}
pub fn line_height_for(&self, font_size: f32) -> f32 {
match &self.line_height {
Some(LineHeight::Number(n)) => n * font_size,
Some(LineHeight::Length(l)) => l.px(),
_ => font_size * 1.3,
}
}
pub fn font_size_px_ctx(&self, ctx: &LengthContext, default: f32) -> f32 {
self.font_size
.as_ref()
.and_then(|l| l.parse().resolve(ctx))
.unwrap_or(default)
}
pub fn letter_spacing_px_ctx(&self, ctx: &LengthContext) -> f32 {
self.letter_spacing
.as_ref()
.and_then(|l| l.parse().resolve(ctx))
.unwrap_or(0.0)
}
pub fn line_height_for_ctx(&self, font_size: f32, ctx: &LengthContext) -> f32 {
match &self.line_height {
Some(LineHeight::Number(n)) => n * font_size,
Some(LineHeight::Length(lp)) => match lp.parse() {
ParsedLength::Percent(p) => p / 100.0 * font_size,
other => other.resolve(ctx).unwrap_or(font_size * 1.3),
},
_ => font_size * 1.3,
}
}
pub fn typography_px_ctx(
&self,
ctx: &LengthContext,
default_font_size: f32,
) -> (f32, f32, f32) {
let font_size = self.font_size_px_ctx(ctx, default_font_size);
let own_ctx = LengthContext { font_size, ..*ctx };
let letter_spacing = self.letter_spacing_px_ctx(&own_ctx);
let line_height = self.line_height_for_ctx(font_size, &own_ctx);
(font_size, letter_spacing, line_height)
}
pub fn opacity_or(&self, default: f32) -> f32 {
self.opacity.unwrap_or(default)
}
pub fn border_radius_px(&self) -> Option<f32> {
match &self.border_radius {
Some(BorderRadius::Uniform(lp)) => Some(lp.px()),
Some(BorderRadius::Corners { top_left, .. }) => Some(top_left.px()),
None => None,
}
}
pub fn border_radius_px_or(&self, default: f32) -> f32 {
self.border_radius_px().unwrap_or(default)
}
pub fn padding_px(&self) -> (f32, f32, f32, f32) {
edges_px(self.padding.as_ref())
}
pub fn margin_px(&self) -> (f32, f32, f32, f32) {
edges_px(self.margin.as_ref())
}
pub fn background_color_str(&self) -> Option<&str> {
match &self.background {
Some(Background::Color(Color::String(s))) => Some(s.as_str()),
_ => None,
}
}
}
fn edges_px(e: Option<&Edges>) -> (f32, f32, f32, f32) {
match e {
Some(Edges::Uniform(v)) => {
let p = v.px();
(p, p, p, p)
}
Some(Edges::Sides {
top,
right,
bottom,
left,
}) => (top.px(), right.px(), bottom.px(), left.px()),
None => (0.0, 0.0, 0.0, 0.0),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum Display {
Block,
Flex,
Grid,
InlineBlock,
None,
Contents,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum Position {
Static,
Relative,
Absolute,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum BoxSizing {
ContentBox,
BorderBox,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum Overflow {
Visible,
Hidden,
Auto,
Scroll,
Clip,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum Visibility {
Visible,
Hidden,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum Size {
Auto(AutoKw),
Keyword(SizeKeyword),
Length(LengthPercentage),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum AutoKw {
Auto,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum SizeKeyword {
MaxContent,
MinContent,
FitContent,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(untagged, deny_unknown_fields)]
pub enum Edges {
Uniform(LengthPercentage),
Sides {
#[serde(default)]
top: LengthPercentage,
#[serde(default)]
right: LengthPercentage,
#[serde(default)]
bottom: LengthPercentage,
#[serde(default)]
left: LengthPercentage,
},
}
impl Edges {
pub fn resolve(
&self,
) -> (
LengthPercentage,
LengthPercentage,
LengthPercentage,
LengthPercentage,
) {
match self {
Edges::Uniform(v) => (v.clone(), v.clone(), v.clone(), v.clone()),
Edges::Sides {
top,
right,
bottom,
left,
} => (top.clone(), right.clone(), bottom.clone(), left.clone()),
}
}
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
pub struct BorderEdges {
pub width: Option<Edges>,
pub style: Option<BorderStyle>,
pub color: Option<Color>,
pub top: Option<BorderSide>,
pub right: Option<BorderSide>,
pub bottom: Option<BorderSide>,
pub left: Option<BorderSide>,
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
pub struct BorderSide {
pub width: Option<Length>,
pub style: Option<BorderStyle>,
pub color: Option<Color>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum BorderStyle {
None,
Solid,
Dashed,
Dotted,
Double,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(untagged, deny_unknown_fields)]
pub enum BorderRadius {
Uniform(LengthPercentage),
Corners {
#[serde(default, alias = "top_left")]
#[serde(rename = "top-left")]
top_left: LengthPercentage,
#[serde(default, alias = "top_right")]
#[serde(rename = "top-right")]
top_right: LengthPercentage,
#[serde(default, alias = "bottom_right")]
#[serde(rename = "bottom-right")]
bottom_right: LengthPercentage,
#[serde(default, alias = "bottom_left")]
#[serde(rename = "bottom-left")]
bottom_left: LengthPercentage,
},
}
impl BorderRadius {
pub fn absolute_px(&self) -> Option<f32> {
match self {
BorderRadius::Uniform(lp) => match lp.try_parse() {
Some(crate::css::units::ParsedLength::Px(v)) => Some(v),
_ => None,
},
BorderRadius::Corners { .. } => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum FlexDirection {
Row,
RowReverse,
Column,
ColumnReverse,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum FlexWrap {
Nowrap,
Wrap,
WrapReverse,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum JustifyContent {
FlexStart,
FlexEnd,
Center,
SpaceBetween,
SpaceAround,
SpaceEvenly,
Start,
End,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum AlignItems {
Stretch,
FlexStart,
FlexEnd,
Center,
Baseline,
Start,
End,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum AlignSelf {
Auto,
Stretch,
FlexStart,
FlexEnd,
Center,
Baseline,
Start,
End,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum AlignContent {
Stretch,
FlexStart,
FlexEnd,
Center,
SpaceBetween,
SpaceAround,
SpaceEvenly,
Start,
End,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum Gap {
Uniform(LengthPercentage),
RowColumn {
row: LengthPercentage,
column: LengthPercentage,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum GridTrack {
Fr(f32),
Keyword(GridTrackKeyword),
Length(LengthPercentage),
Minmax {
min: Box<GridTrack>,
max: Box<GridTrack>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum GridTrackKeyword {
Auto,
MinContent,
MaxContent,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
#[derive(Default)]
pub struct GridLine {
pub start: Option<GridLineEnd>,
pub end: Option<GridLineEnd>,
pub span: Option<u16>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum GridLineEnd {
Index(i32),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum GridAutoFlow {
Row,
Column,
RowDense,
ColumnDense,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum JustifyItems {
Stretch,
Start,
End,
Center,
Legacy,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum JustifySelf {
Auto,
Stretch,
Start,
End,
Center,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum FontWeight {
Keyword(FontWeightKw),
Number(u16),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum FontWeightKw {
Normal,
Bold,
Bolder,
Lighter,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum FontStyle {
Normal,
Italic,
Oblique,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum LineHeight {
Number(f32),
Keyword(LineHeightKw),
Length(LengthPercentage),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum LineHeightKw {
Normal,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum TextAlign {
Left,
Right,
Center,
Justify,
Start,
End,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum WhiteSpace {
Normal,
Nowrap,
Pre,
PreLine,
PreWrap,
BreakSpaces,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum OverflowWrap {
Normal,
BreakWord,
Anywhere,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum TextOverflow {
Clip,
Ellipsis,
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
pub struct TextDecoration {
pub line: Option<TextDecorationLine>,
pub style: Option<TextDecorationStyle>,
pub color: Option<Color>,
pub thickness: Option<Length>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum TextDecorationLine {
None,
Underline,
Overline,
LineThrough,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum TextDecorationStyle {
Solid,
Double,
Dotted,
Dashed,
Wavy,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum Color {
String(String),
Rgba {
r: u8,
g: u8,
b: u8,
#[serde(default = "one_f32")]
a: f32,
},
}
fn one_f32() -> f32 {
1.0
}
impl Color {
pub fn to_css_string(&self) -> String {
match self {
Color::String(s) => s.clone(),
Color::Rgba { r, g, b, a } => {
if *a >= 1.0 {
format!("#{r:02x}{g:02x}{b:02x}")
} else {
let alpha = (a.clamp(0.0, 1.0) * 255.0) as u8;
format!("#{r:02x}{g:02x}{b:02x}{alpha:02x}")
}
}
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum Background {
Color(Color),
Layers(Vec<BackgroundLayer>),
Single(BackgroundLayer),
}
impl Background {
pub fn solid_hex(&self) -> Option<String> {
match self {
Background::Color(c) => Some(c.to_css_string()),
Background::Layers(_) | Background::Single(_) => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum BackgroundLayer {
Color {
color: Color,
},
LinearGradient {
#[serde(default)]
angle: Option<f32>,
stops: Vec<GradientStop>,
},
RadialGradient {
#[serde(default)]
shape: Option<RadialShape>,
#[serde(default)]
position: Option<TransformOrigin>,
stops: Vec<GradientStop>,
},
ConicGradient {
#[serde(default)]
from: Option<f32>,
#[serde(default)]
position: Option<TransformOrigin>,
stops: Vec<GradientStop>,
},
Image {
url: String,
#[serde(default)]
size: Option<BackgroundSize>,
#[serde(default)]
position: Option<TransformOrigin>,
#[serde(default)]
repeat: Option<BackgroundRepeat>,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
pub struct GradientStop {
pub color: Color,
pub offset: Option<f32>,
}
impl Default for GradientStop {
fn default() -> Self {
Self {
color: Color::String("#000000".into()),
offset: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum RadialShape {
Circle,
Ellipse,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum BackgroundSize {
Cover,
Contain,
Auto,
Length {
width: LengthPercentage,
height: LengthPercentage,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum BackgroundRepeat {
Repeat,
NoRepeat,
RepeatX,
RepeatY,
Round,
Space,
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
pub struct BoxShadow {
pub offset_x: Length,
pub offset_y: Length,
pub blur: Option<Length>,
pub spread: Option<Length>,
pub color: Option<Color>,
pub inset: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
pub struct TextShadow {
pub offset_x: Length,
pub offset_y: Length,
pub blur: Option<Length>,
pub color: Option<Color>,
}
impl TextShadow {
pub fn to_schema(&self, ctx: &crate::css::units::LengthContext) -> crate::schema::TextShadow {
crate::schema::TextShadow {
color: self
.color
.as_ref()
.map(Color::to_css_string)
.unwrap_or_else(|| "#000000".to_string()),
offset_x: self.offset_x.resolve(ctx),
offset_y: self.offset_y.resolve(ctx),
blur: self.blur.as_ref().map(|b| b.resolve(ctx)).unwrap_or(0.0),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "fn", rename_all = "kebab-case")]
pub enum TransformFn {
Translate {
x: LengthPercentage,
#[serde(default)]
y: LengthPercentage,
},
TranslateX {
x: LengthPercentage,
},
TranslateY {
y: LengthPercentage,
},
TranslateZ {
z: Length,
},
Translate3d {
x: LengthPercentage,
y: LengthPercentage,
z: Length,
},
Scale {
x: f32,
#[serde(default = "one_f32")]
y: f32,
},
ScaleX {
x: f32,
},
ScaleY {
y: f32,
},
ScaleZ {
z: f32,
},
Scale3d {
x: f32,
y: f32,
z: f32,
},
Rotate {
deg: f32,
},
RotateX {
deg: f32,
},
RotateY {
deg: f32,
},
RotateZ {
deg: f32,
},
Rotate3d {
x: f32,
y: f32,
z: f32,
deg: f32,
},
Skew {
x: f32,
#[serde(default)]
y: f32,
},
SkewX {
x: f32,
},
SkewY {
y: f32,
},
Perspective {
length: Length,
},
Matrix {
values: [f32; 6],
},
Matrix3d {
values: [f32; 16],
},
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
pub struct TransformOrigin {
pub x: Option<LengthPercentage>,
pub y: Option<LengthPercentage>,
pub z: Option<Length>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "fn", rename_all = "kebab-case")]
pub enum FilterFn {
Blur {
radius: Length,
},
Brightness {
value: f32,
},
Contrast {
value: f32,
},
Saturate {
value: f32,
},
HueRotate {
deg: f32,
},
Grayscale {
value: f32,
},
Invert {
value: f32,
},
Sepia {
value: f32,
},
DropShadow {
offset_x: Length,
offset_y: Length,
#[serde(default)]
blur: Option<Length>,
#[serde(default)]
color: Option<Color>,
},
Opacity {
value: f32,
},
Noise {
#[serde(default = "default_noise_intensity")]
intensity: f32,
#[serde(default = "default_noise_seed")]
seed: u64,
},
}
fn default_noise_intensity() -> f32 {
0.15
}
fn default_noise_seed() -> u64 {
42
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum BlendMode {
Normal,
Multiply,
Screen,
Overlay,
Darken,
Lighten,
ColorDodge,
ColorBurn,
HardLight,
SoftLight,
Difference,
Exclusion,
Hue,
Saturation,
Color,
Luminosity,
PlusLighter,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum ClipPath {
None,
Inset {
top: LengthPercentage,
right: LengthPercentage,
bottom: LengthPercentage,
left: LengthPercentage,
#[serde(default)]
radius: Option<BorderRadius>,
},
Circle {
radius: LengthPercentage,
#[serde(default)]
origin: Option<TransformOrigin>,
},
Ellipse {
rx: LengthPercentage,
ry: LengthPercentage,
#[serde(default)]
origin: Option<TransformOrigin>,
},
Polygon {
points: Vec<(LengthPercentage, LengthPercentage)>,
},
Path {
d: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_is_all_none() {
let s = CssStyle::default();
assert!(s.display.is_none());
assert!(s.padding.is_none());
assert!(s.transform.is_none());
}
#[test]
fn deserialize_basic_flex() {
let json = r#"{
"display": "flex",
"flex-direction": "column",
"gap": "16px",
"align-items": "center",
"padding": "24px"
}"#;
let s: CssStyle = serde_json::from_str(json).unwrap();
assert_eq!(s.display, Some(Display::Flex));
assert_eq!(s.flex_direction, Some(FlexDirection::Column));
assert_eq!(s.align_items, Some(AlignItems::Center));
assert!(matches!(s.padding, Some(Edges::Uniform(_))));
}
#[test]
fn deserialize_per_side_padding() {
let json = r#"{ "padding": { "top": "10px", "right": "20px", "bottom": "10px", "left": "20px" } }"#;
let s: CssStyle = serde_json::from_str(json).unwrap();
assert!(matches!(s.padding, Some(Edges::Sides { .. })));
}
#[test]
fn deserialize_color_variants() {
let s1: CssStyle = serde_json::from_str(r##"{ "color": "#ff0000" }"##).unwrap();
let s2: CssStyle =
serde_json::from_str(r##"{ "color": { "r": 255, "g": 0, "b": 0, "a": 1.0 } }"##)
.unwrap();
assert!(matches!(s1.color, Some(Color::String(_))));
assert!(matches!(s2.color, Some(Color::Rgba { r: 255, .. })));
}
#[test]
fn deserialize_transform_list() {
let json = r#"{ "transform": [
{ "fn": "translate-x", "x": "10px" },
{ "fn": "scale", "x": 1.5, "y": 1.5 },
{ "fn": "rotate", "deg": 45.0 }
]}"#;
let s: CssStyle = serde_json::from_str(json).unwrap();
let t = s.transform.expect("transform set");
assert_eq!(t.len(), 3);
}
#[test]
fn roundtrip_serialization() {
let original = CssStyle {
display: Some(Display::Flex),
opacity: Some(0.5),
z_index: Some(10),
..Default::default()
};
let json = serde_json::to_string(&original).unwrap();
let parsed: CssStyle = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.display, Some(Display::Flex));
assert_eq!(parsed.opacity, Some(0.5));
assert_eq!(parsed.z_index, Some(10));
}
#[test]
fn grid_track_bare_number_is_fr() {
let json = r#"{ "grid-template-columns": [1, 1, 1] }"#;
let s: CssStyle = serde_json::from_str(json).unwrap();
let tracks = s.grid_template_columns.expect("tracks set");
assert_eq!(tracks.len(), 3);
for t in &tracks {
assert!(matches!(t, GridTrack::Fr(n) if (*n - 1.0).abs() < f32::EPSILON));
}
}
#[test]
fn grid_track_string_fr_is_length_parsed_as_fr() {
let json = r#"{ "grid-template-columns": ["1fr", "2fr"] }"#;
let s: CssStyle = serde_json::from_str(json).unwrap();
let tracks = s.grid_template_columns.expect("tracks set");
match &tracks[0] {
GridTrack::Length(lp) => {
assert_eq!(lp.parse(), crate::css::units::ParsedLength::Fr(1.0))
}
other => panic!("expected Length(\"1fr\"), got {other:?}"),
}
match &tracks[1] {
GridTrack::Length(lp) => {
assert_eq!(lp.parse(), crate::css::units::ParsedLength::Fr(2.0))
}
other => panic!("expected Length(\"2fr\"), got {other:?}"),
}
}
#[test]
fn grid_track_keyword_strings() {
let json = r#"{ "grid-template-columns": ["auto", "min-content", "max-content"] }"#;
let s: CssStyle = serde_json::from_str(json).unwrap();
let tracks = s.grid_template_columns.expect("tracks set");
assert!(matches!(
tracks[0],
GridTrack::Keyword(GridTrackKeyword::Auto)
));
assert!(matches!(
tracks[1],
GridTrack::Keyword(GridTrackKeyword::MinContent)
));
assert!(matches!(
tracks[2],
GridTrack::Keyword(GridTrackKeyword::MaxContent)
));
}
#[test]
fn grid_track_px_string_is_length() {
let json = r#"{ "grid-template-columns": ["200px", "50%"] }"#;
let s: CssStyle = serde_json::from_str(json).unwrap();
let tracks = s.grid_template_columns.expect("tracks set");
match &tracks[0] {
GridTrack::Length(lp) => {
assert_eq!(lp.parse(), crate::css::units::ParsedLength::Px(200.0))
}
other => panic!("expected Length(200px), got {other:?}"),
}
match &tracks[1] {
GridTrack::Length(lp) => {
assert_eq!(lp.parse(), crate::css::units::ParsedLength::Percent(50.0))
}
other => panic!("expected Length(50%), got {other:?}"),
}
}
fn style_with(font_size: &str, letter_spacing: &str, line_height: &str) -> CssStyle {
let json = format!(
r#"{{ "font-size": {font_size}, "letter-spacing": {letter_spacing}, "line-height": {line_height} }}"#
);
serde_json::from_str(&json).unwrap()
}
#[test]
fn font_size_px_ctx_resolves_vw() {
let s = style_with(r#""15.6vw""#, "0", "1");
let ctx = LengthContext {
viewport_width: 1920.0,
..Default::default()
};
assert_eq!(s.font_size_px_ctx(&ctx, 48.0), 15.6 / 100.0 * 1920.0);
assert_eq!(s.font_size_px_or(48.0), 0.0);
}
#[test]
fn font_size_px_ctx_resolves_rem_without_cascade_dependency() {
let s = style_with(r#""2rem""#, "0", "1");
let ctx = LengthContext {
root_font_size: 20.0,
..Default::default()
};
assert_eq!(s.font_size_px_ctx(&ctx, 48.0), 40.0);
}
#[test]
fn font_size_px_ctx_falls_back_to_default_when_unset() {
let s = CssStyle::default();
assert_eq!(s.font_size_px_ctx(&LengthContext::default(), 48.0), 48.0);
}
#[test]
fn letter_spacing_px_ctx_resolves_own_em_not_parent_em() {
let s = style_with("300", r#""-0.03em""#, "1");
let own_ctx = LengthContext {
font_size: 300.0, ..Default::default()
};
assert!((s.letter_spacing_px_ctx(&own_ctx) - (-9.0)).abs() < 1e-4);
assert_eq!(s.letter_spacing_px(), 0.0);
}
#[test]
fn line_height_percent_resolves_against_own_font_size_not_parent_size() {
let s = style_with("100", r#""50%""#, r#""50%""#);
let ctx = LengthContext {
parent_size: 1000.0, ..Default::default()
};
assert_eq!(s.line_height_for_ctx(100.0, &ctx), 50.0);
}
#[test]
fn line_height_number_ignores_context_like_before() {
let s = style_with("100", "0", "1.5");
assert_eq!(
s.line_height_for_ctx(100.0, &LengthContext::default()),
150.0
);
}
#[test]
fn typography_px_ctx_resolves_all_three_with_correct_em_bases() {
let s = style_with(r#""1.5em""#, r#""-0.03em""#, "0.85");
let ctx = LengthContext {
font_size: 200.0, ..Default::default()
};
let (font_size, letter_spacing, line_height) = s.typography_px_ctx(&ctx, 48.0);
assert_eq!(font_size, 300.0);
assert!(
(letter_spacing - (300.0 * -0.03)).abs() < 1e-3,
"letter-spacing em must resolve against the OWN 300px font-size, got {letter_spacing}"
);
assert_eq!(line_height, 300.0 * 0.85);
}
#[test]
fn border_radius_corners_accepts_kebab_case() {
let json = r#"{ "border-radius": { "top-left": "12px", "top-right": "12px", "bottom-right": "4px", "bottom-left": "4px" } }"#;
let s: CssStyle = serde_json::from_str(json).unwrap();
match s.border_radius {
Some(BorderRadius::Corners {
top_left,
top_right,
bottom_right,
bottom_left,
}) => {
assert_eq!(top_left.px(), 12.0, "top-left must be honoured, not 0");
assert_eq!(top_right.px(), 12.0);
assert_eq!(bottom_right.px(), 4.0);
assert_eq!(bottom_left.px(), 4.0);
}
other => panic!("expected Corners, got {other:?}"),
}
}
#[test]
fn border_radius_corners_still_accepts_legacy_snake_case() {
let json = r#"{ "border-radius": { "top_left": "8px", "top_right": "8px", "bottom_right": "8px", "bottom_left": "8px" } }"#;
let s: CssStyle = serde_json::from_str(json).unwrap();
assert_eq!(s.border_radius_px(), Some(8.0));
}
#[test]
fn border_radius_corners_typo_is_a_named_error_not_a_silent_zero() {
let json = r#"{ "border-radius": { "topleft": "12px" } }"#;
let err = serde_json::from_str::<CssStyle>(json).expect_err("typo must be rejected");
let msg = err.to_string();
assert!(
msg.contains("topleft")
|| msg.contains("border-radius")
|| msg.contains("BorderRadius"),
"error must name the offending input, got: {msg}"
);
}
#[test]
fn edges_rejects_unknown_object_shape_instead_of_defaulting_to_zero() {
let json = r#"{ "padding": { "horizontal": 20 } }"#;
let err = serde_json::from_str::<CssStyle>(json)
.expect_err("an unrecognised padding shape must be rejected, not silently zeroed");
let msg = err.to_string();
assert!(
msg.contains("horizontal") || msg.contains("padding") || msg.contains("Edges"),
"error must name the offending input, got: {msg}"
);
}
#[test]
fn edges_still_accepts_valid_per_side_object() {
let json = r#"{ "padding": { "top": "10px", "right": "20px", "bottom": "10px", "left": "20px" } }"#;
let s: CssStyle = serde_json::from_str(json).unwrap();
assert_eq!(s.padding_px(), (10.0, 20.0, 10.0, 20.0));
}
#[test]
fn edges_still_accepts_uniform_scalar() {
let json = r#"{ "padding": "24px" }"#;
let s: CssStyle = serde_json::from_str(json).unwrap();
assert_eq!(s.padding_px(), (24.0, 24.0, 24.0, 24.0));
}
#[test]
fn size_keyword_max_content_is_reachable() {
for (kw, expected) in [
("max-content", SizeKeyword::MaxContent),
("min-content", SizeKeyword::MinContent),
("fit-content", SizeKeyword::FitContent),
] {
let json = format!(r#"{{ "width": "{kw}" }}"#);
let s: CssStyle = serde_json::from_str(&json).unwrap();
assert_eq!(
s.width,
Some(Size::Keyword(expected)),
"width: \"{kw}\" must resolve to Size::Keyword, not Size::Length(String(..))"
);
}
}
#[test]
fn size_length_and_auto_are_unaffected_by_the_reorder() {
let s: CssStyle = serde_json::from_str(r#"{ "width": "200px" }"#).unwrap();
assert!(matches!(s.width, Some(Size::Length(_))));
let s: CssStyle = serde_json::from_str(r#"{ "width": "50%" }"#).unwrap();
assert!(matches!(s.width, Some(Size::Length(_))));
let s: CssStyle = serde_json::from_str(r#"{ "width": "auto" }"#).unwrap();
assert!(matches!(s.width, Some(Size::Auto(_))));
let s: CssStyle = serde_json::from_str(r#"{ "width": 200 }"#).unwrap();
assert!(matches!(s.width, Some(Size::Length(_))));
}
#[test]
fn line_height_keyword_normal_is_reachable() {
let s: CssStyle = serde_json::from_str(r#"{ "line-height": "normal" }"#).unwrap();
assert_eq!(
s.line_height,
Some(LineHeight::Keyword(LineHeightKw::Normal)),
"line-height: \"normal\" must resolve to Keyword, not Length(String(\"normal\"))"
);
}
#[test]
fn line_height_number_and_length_are_unaffected_by_the_reorder() {
let s: CssStyle = serde_json::from_str(r#"{ "line-height": 1.5 }"#).unwrap();
assert!(matches!(s.line_height, Some(LineHeight::Number(_))));
let s: CssStyle = serde_json::from_str(r#"{ "line-height": "24px" }"#).unwrap();
assert!(matches!(s.line_height, Some(LineHeight::Length(_))));
}
#[test]
fn border_radius_corners_serializes_as_kebab_case() {
let s = CssStyle {
border_radius: Some(BorderRadius::Corners {
top_left: LengthPercentage::Px(1.0),
top_right: LengthPercentage::Px(2.0),
bottom_right: LengthPercentage::Px(3.0),
bottom_left: LengthPercentage::Px(4.0),
}),
..Default::default()
};
let json = serde_json::to_value(&s).unwrap();
let br = &json["border-radius"];
assert_eq!(br["top-left"], serde_json::json!(1.0));
assert_eq!(br["top-right"], serde_json::json!(2.0));
assert_eq!(br["bottom-right"], serde_json::json!(3.0));
assert_eq!(br["bottom-left"], serde_json::json!(4.0));
assert!(
br.get("top_left").is_none(),
"must not emit the legacy snake_case key any more"
);
}
}