Skip to main content

cdx_core/presentation/
style.rs

1//! Styling types for presentation layers.
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6/// A map of style names to style definitions.
7pub type StyleMap = HashMap<String, Style>;
8
9/// Writing mode for text direction.
10///
11/// Controls the direction in which text flows within a block.
12/// This is particularly important for CJK (Chinese, Japanese, Korean)
13/// languages which can be written vertically.
14#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "kebab-case")]
16pub enum WritingMode {
17    /// Horizontal text, top-to-bottom block flow (default).
18    /// Used for Latin, Cyrillic, Arabic, Hebrew scripts.
19    #[default]
20    HorizontalTb,
21
22    /// Vertical text, right-to-left block flow.
23    /// Traditional Chinese, Japanese, Korean.
24    VerticalRl,
25
26    /// Vertical text, left-to-right block flow.
27    /// Used for Mongolian script.
28    VerticalLr,
29
30    /// Sideways text, right-to-left (90° clockwise rotation).
31    SidewaysRl,
32
33    /// Sideways text, left-to-right (90° counter-clockwise rotation).
34    SidewaysLr,
35}
36
37/// 2D transform for element positioning.
38///
39/// Transforms allow rotation, scaling, skewing, and translation
40/// of elements in paginated and precise layouts.
41#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
42#[serde(rename_all = "camelCase")]
43pub struct Transform {
44    /// Rotation angle (e.g., "90deg", "-45deg", "0.5rad").
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub rotate: Option<String>,
47
48    /// Scale factor (uniform or non-uniform).
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub scale: Option<Scale>,
51
52    /// Skew along X-axis (angle).
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub skew_x: Option<String>,
55
56    /// Skew along Y-axis (angle).
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub skew_y: Option<String>,
59
60    /// Translation along X-axis (length).
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub translate_x: Option<String>,
63
64    /// Translation along Y-axis (length).
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub translate_y: Option<String>,
67
68    /// 2D transformation matrix [a, b, c, d, tx, ty].
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub matrix: Option<[f64; 6]>,
71
72    /// Transform origin point.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub origin: Option<TransformOrigin>,
75}
76
77impl Transform {
78    /// Create a rotation transform.
79    #[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    /// Create a uniform scale transform.
88    #[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    /// Create a non-uniform scale transform.
97    #[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    /// Create a translation transform.
106    #[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    /// Set the transform origin.
116    #[must_use]
117    pub fn with_origin(mut self, origin: TransformOrigin) -> Self {
118        self.origin = Some(origin);
119        self
120    }
121}
122
123/// Scale factor for transforms.
124#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
125#[serde(untagged)]
126pub enum Scale {
127    /// Uniform scaling (same factor for X and Y).
128    Uniform(f64),
129    /// Non-uniform scaling (different factors for X and Y).
130    NonUniform {
131        /// X scale factor.
132        x: f64,
133        /// Y scale factor.
134        y: f64,
135    },
136}
137
138/// Transform origin point.
139#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
140#[serde(untagged)]
141pub enum TransformOrigin {
142    /// Keyword origin (e.g., "center", "top left").
143    Keyword(String),
144    /// Explicit coordinate origin.
145    Point {
146        /// X coordinate.
147        x: String,
148        /// Y coordinate.
149        y: String,
150    },
151}
152
153impl TransformOrigin {
154    /// Center origin.
155    #[must_use]
156    pub fn center() -> Self {
157        Self::Keyword("center".to_string())
158    }
159
160    /// Top-left origin.
161    #[must_use]
162    pub fn top_left() -> Self {
163        Self::Keyword("top left".to_string())
164    }
165
166    /// Custom point origin.
167    #[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/// CSS-like style properties.
177#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
178#[serde(rename_all = "camelCase")]
179pub struct Style {
180    // Typography
181    /// Font family stack.
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub font_family: Option<String>,
184
185    /// Font size with units.
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub font_size: Option<CssValue>,
188
189    /// Font weight (100-900 or keyword).
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub font_weight: Option<FontWeight>,
192
193    /// Font style (normal, italic).
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub font_style: Option<String>,
196
197    /// Line height (unitless ratio or with units).
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub line_height: Option<CssValue>,
200
201    /// Letter spacing.
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub letter_spacing: Option<CssValue>,
204
205    /// Text alignment.
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub text_align: Option<TextAlign>,
208
209    /// Text decoration.
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub text_decoration: Option<String>,
212
213    /// Text transform.
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub text_transform: Option<String>,
216
217    /// Text color.
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub color: Option<Color>,
220
221    // Spacing
222    /// Top margin.
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub margin_top: Option<CssValue>,
225
226    /// Right margin.
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub margin_right: Option<CssValue>,
229
230    /// Bottom margin.
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub margin_bottom: Option<CssValue>,
233
234    /// Left margin.
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub margin_left: Option<CssValue>,
237
238    /// Top padding.
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub padding_top: Option<CssValue>,
241
242    /// Right padding.
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub padding_right: Option<CssValue>,
245
246    /// Bottom padding.
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub padding_bottom: Option<CssValue>,
249
250    /// Left padding.
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    pub padding_left: Option<CssValue>,
253
254    // Borders
255    /// Border width.
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub border_width: Option<CssValue>,
258
259    /// Border style.
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub border_style: Option<String>,
262
263    /// Border color.
264    #[serde(default, skip_serializing_if = "Option::is_none")]
265    pub border_color: Option<Color>,
266
267    // Background
268    /// Background color.
269    #[serde(default, skip_serializing_if = "Option::is_none")]
270    pub background_color: Option<Color>,
271
272    // Layout
273    /// Width.
274    #[serde(default, skip_serializing_if = "Option::is_none")]
275    pub width: Option<CssValue>,
276
277    /// Height.
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub height: Option<CssValue>,
280
281    /// Maximum width.
282    #[serde(default, skip_serializing_if = "Option::is_none")]
283    pub max_width: Option<CssValue>,
284
285    /// Maximum height.
286    #[serde(default, skip_serializing_if = "Option::is_none")]
287    pub max_height: Option<CssValue>,
288
289    // Page breaks (for print)
290    /// Page break before.
291    #[serde(default, skip_serializing_if = "Option::is_none")]
292    pub page_break_before: Option<String>,
293
294    /// Page break after.
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub page_break_after: Option<String>,
297
298    // Inheritance
299    /// Style to inherit from.
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    pub extends: Option<String>,
302
303    // Writing mode
304    /// Writing mode for text direction.
305    #[serde(default, skip_serializing_if = "Option::is_none")]
306    pub writing_mode: Option<WritingMode>,
307
308    // Stacking
309    /// Z-index for stacking order.
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub z_index: Option<i32>,
312
313    // Background images
314    /// Background image URL.
315    #[serde(default, skip_serializing_if = "Option::is_none")]
316    pub background_image: Option<String>,
317
318    /// Background size.
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub background_size: Option<String>,
321
322    /// Background position.
323    #[serde(default, skip_serializing_if = "Option::is_none")]
324    pub background_position: Option<String>,
325
326    /// Background repeat.
327    #[serde(default, skip_serializing_if = "Option::is_none")]
328    pub background_repeat: Option<String>,
329
330    // Visual effects
331    /// Element opacity (0.0 to 1.0).
332    #[serde(default, skip_serializing_if = "Option::is_none")]
333    pub opacity: Option<f32>,
334
335    /// Border radius.
336    #[serde(default, skip_serializing_if = "Option::is_none")]
337    pub border_radius: Option<CssValue>,
338
339    /// Box shadow.
340    #[serde(default, skip_serializing_if = "Option::is_none")]
341    pub box_shadow: Option<String>,
342}
343
344/// CSS value with units.
345#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
346#[serde(untagged)]
347pub enum CssValue {
348    /// Numeric value (for unitless values like line-height).
349    Number(f32),
350    /// String value with units (e.g., "16px", "1.5em").
351    String(String),
352}
353
354impl CssValue {
355    /// Create a pixel value.
356    #[must_use]
357    pub fn px(value: f32) -> Self {
358        Self::String(format!("{value}px"))
359    }
360
361    /// Create a point value.
362    #[must_use]
363    pub fn pt(value: f32) -> Self {
364        Self::String(format!("{value}pt"))
365    }
366
367    /// Create an em value.
368    #[must_use]
369    pub fn em(value: f32) -> Self {
370        Self::String(format!("{value}em"))
371    }
372
373    /// Create a rem value.
374    #[must_use]
375    pub fn rem(value: f32) -> Self {
376        Self::String(format!("{value}rem"))
377    }
378
379    /// Create a percentage value.
380    #[must_use]
381    pub fn percent(value: f32) -> Self {
382        Self::String(format!("{value}%"))
383    }
384
385    /// Create an inch value.
386    #[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/// Font weight.
405#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
406#[serde(untagged)]
407pub enum FontWeight {
408    /// Numeric weight (100-900).
409    Number(u16),
410    /// Keyword (normal, bold, etc.).
411    Keyword(String),
412}
413
414impl FontWeight {
415    /// Normal weight (400).
416    #[must_use]
417    pub fn normal() -> Self {
418        Self::Number(400)
419    }
420
421    /// Bold weight (700).
422    #[must_use]
423    pub fn bold() -> Self {
424        Self::Number(700)
425    }
426}
427
428/// Text alignment.
429#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
430#[serde(rename_all = "lowercase")]
431pub enum TextAlign {
432    /// Left alignment.
433    Left,
434    /// Center alignment.
435    Center,
436    /// Right alignment.
437    Right,
438    /// Justified alignment.
439    Justify,
440}
441
442/// Color value.
443#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
444#[serde(untagged)]
445pub enum Color {
446    /// Named color or hex value.
447    Named(String),
448}
449
450impl Color {
451    /// Create a hex color.
452    #[must_use]
453    pub fn hex(value: impl Into<String>) -> Self {
454        Self::Named(value.into())
455    }
456
457    /// Black color.
458    #[must_use]
459    pub fn black() -> Self {
460        Self::Named("black".to_string())
461    }
462
463    /// White color.
464    #[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}