Skip to main content

omni_dev/drive/docs/
style.rs

1//! Bounded style patches: field masks are derived, never supplied by callers.
2use serde::Serialize;
3
4/// Explicit character formatting. Omitted properties are unchanged.
5#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
6pub struct TextStyle {
7    /// Explicit bold value, including false.
8    #[serde(skip_serializing_if = "Option::is_none")]
9    pub bold: Option<bool>,
10    /// Explicit italic value.
11    #[serde(skip_serializing_if = "Option::is_none")]
12    pub italic: Option<bool>,
13    /// Explicit underline value.
14    #[serde(skip_serializing_if = "Option::is_none")]
15    pub underline: Option<bool>,
16    /// Explicit strikethrough value.
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub strikethrough: Option<bool>,
19}
20
21/// Supported paragraph alignment values.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, clap::ValueEnum)]
23#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
24pub enum Alignment {
25    /// Leading edge in the paragraph's writing direction.
26    Start,
27    /// Centered.
28    Center,
29    /// Trailing edge in the paragraph's writing direction.
30    End,
31    /// Justified.
32    Justified,
33}
34
35/// Supported named paragraph styles.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, clap::ValueEnum)]
37#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
38pub enum NamedStyle {
39    /// Ordinary prose.
40    NormalText,
41    /// Document title.
42    Title,
43    /// Document subtitle.
44    Subtitle,
45    /// Heading level one.
46    #[serde(rename = "HEADING_1")]
47    Heading1,
48    /// Heading level two.
49    #[serde(rename = "HEADING_2")]
50    Heading2,
51    /// Heading level three.
52    #[serde(rename = "HEADING_3")]
53    Heading3,
54    /// Heading level four.
55    #[serde(rename = "HEADING_4")]
56    Heading4,
57    /// Heading level five.
58    #[serde(rename = "HEADING_5")]
59    Heading5,
60    /// Heading level six.
61    #[serde(rename = "HEADING_6")]
62    Heading6,
63}
64
65/// Explicit paragraph formatting. Omitted properties are unchanged.
66#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
67pub struct ParagraphStyle {
68    /// Paragraph alignment.
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub alignment: Option<Alignment>,
71    /// Named paragraph style; Google may change inherited formatting.
72    #[serde(rename = "namedStyleType", skip_serializing_if = "Option::is_none")]
73    pub named_style_type: Option<NamedStyle>,
74}
75
76/// Exactly one formatting family per invocation.
77#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
78pub enum StylePatch {
79    /// Character formatting.
80    #[serde(rename = "textStyle")]
81    Text(TextStyle),
82    /// Whole-paragraph formatting.
83    #[serde(rename = "paragraphStyle")]
84    Paragraph(ParagraphStyle),
85}
86
87impl StylePatch {
88    /// Deterministic field mask containing only explicitly supplied properties.
89    #[must_use]
90    pub fn fields(&self) -> String {
91        let fields: Vec<&str> = match self {
92            Self::Text(style) => [
93                (style.bold.is_some(), "bold"),
94                (style.italic.is_some(), "italic"),
95                (style.underline.is_some(), "underline"),
96                (style.strikethrough.is_some(), "strikethrough"),
97            ]
98            .into_iter()
99            .filter_map(|(set, name)| set.then_some(name))
100            .collect(),
101            Self::Paragraph(style) => [
102                (style.alignment.is_some(), "alignment"),
103                (style.named_style_type.is_some(), "namedStyleType"),
104            ]
105            .into_iter()
106            .filter_map(|(set, name)| set.then_some(name))
107            .collect(),
108        };
109        fields.join(",")
110    }
111}
112
113#[cfg(test)]
114#[allow(clippy::unwrap_used)]
115mod tests {
116    use super::*;
117    #[test]
118    fn masks_include_explicit_false_and_only_supplied_fields() {
119        let text = StylePatch::Text(TextStyle {
120            bold: Some(false),
121            underline: Some(true),
122            ..TextStyle::default()
123        });
124        assert_eq!(text.fields(), "bold,underline");
125        assert_eq!(
126            serde_json::to_value(text).unwrap(),
127            serde_json::json!({"textStyle": {"bold": false, "underline": true}})
128        );
129        assert!(StylePatch::Text(TextStyle::default()).fields().is_empty());
130        assert!(StylePatch::Paragraph(ParagraphStyle::default())
131            .fields()
132            .is_empty());
133        assert_eq!(
134            StylePatch::Paragraph(ParagraphStyle {
135                alignment: None,
136                named_style_type: Some(NamedStyle::Title)
137            })
138            .fields(),
139            "namedStyleType"
140        );
141    }
142    #[test]
143    fn named_heading_styles_use_google_enum_spelling() {
144        for (i, style) in [
145            NamedStyle::Heading1,
146            NamedStyle::Heading2,
147            NamedStyle::Heading3,
148            NamedStyle::Heading4,
149            NamedStyle::Heading5,
150            NamedStyle::Heading6,
151        ]
152        .into_iter()
153        .enumerate()
154        {
155            assert_eq!(
156                serde_json::to_value(style).unwrap(),
157                format!("HEADING_{}", i + 1)
158            );
159        }
160    }
161}