Skip to main content

sheetkit_core/
rich_text.rs

1//! Rich text run types and conversion utilities.
2//!
3//! A rich text value is a sequence of [`RichTextRun`] segments, each with its
4//! own formatting. These map to the `<r>` (run) elements inside `<si>` items
5//! in `xl/sharedStrings.xml`.
6
7use sheetkit_xml::shared_strings::{BoolVal, Color, FontName, FontSize, RPr, Si, R, T};
8
9/// A single formatted text segment within a rich text cell.
10#[derive(Debug, Clone, PartialEq)]
11pub struct RichTextRun {
12    /// The plain text content of this run.
13    pub text: String,
14    /// Font name (e.g., "Calibri").
15    pub font: Option<String>,
16    /// Font size in points.
17    pub size: Option<f64>,
18    /// Whether the text is bold.
19    pub bold: bool,
20    /// Whether the text is italic.
21    pub italic: bool,
22    /// Font color as an RGB hex string (e.g., "#FF0000").
23    pub color: Option<String>,
24}
25
26/// Convert a high-level [`RichTextRun`] into an XML `<r>` element.
27pub fn run_to_xml(run: &RichTextRun) -> R {
28    let has_formatting =
29        run.bold || run.italic || run.font.is_some() || run.size.is_some() || run.color.is_some();
30
31    let r_pr = if has_formatting {
32        Some(RPr {
33            b: if run.bold {
34                Some(BoolVal { val: None })
35            } else {
36                None
37            },
38            i: if run.italic {
39                Some(BoolVal { val: None })
40            } else {
41                None
42            },
43            sz: run.size.map(|val| FontSize { val }),
44            color: run.color.as_ref().map(|rgb| Color {
45                rgb: Some(rgb.clone()),
46                theme: None,
47                tint: None,
48            }),
49            r_font: run.font.as_ref().map(|val| FontName { val: val.clone() }),
50            family: None,
51            scheme: None,
52        })
53    } else {
54        None
55    };
56
57    R {
58        r_pr,
59        t: T {
60            xml_space: if run.text.starts_with(' ')
61                || run.text.ends_with(' ')
62                || run.text.contains("  ")
63                || run.text.contains('\n')
64                || run.text.contains('\t')
65            {
66                Some("preserve".to_string())
67            } else {
68                None
69            },
70            value: run.text.clone(),
71        },
72    }
73}
74
75/// Convert an XML `<r>` element into a high-level [`RichTextRun`].
76pub fn xml_to_run(r: &R) -> RichTextRun {
77    let (font, size, bold, italic, color) = if let Some(ref rpr) = r.r_pr {
78        (
79            rpr.r_font.as_ref().map(|f| f.val.clone()),
80            rpr.sz.as_ref().map(|s| s.val),
81            rpr.b.is_some(),
82            rpr.i.is_some(),
83            rpr.color.as_ref().and_then(|c| c.rgb.clone()),
84        )
85    } else {
86        (None, None, false, false, None)
87    };
88
89    RichTextRun {
90        text: r.t.value.clone(),
91        font,
92        size,
93        bold,
94        italic,
95        color,
96    }
97}
98
99/// Convert a slice of [`RichTextRun`] into an XML `<si>` element (for the SST).
100pub fn runs_to_si(runs: &[RichTextRun]) -> Si {
101    Si {
102        t: None,
103        r: runs.iter().map(run_to_xml).collect(),
104    }
105}
106
107/// Extract plain text from a slice of rich text runs.
108pub fn rich_text_to_plain(runs: &[RichTextRun]) -> String {
109    runs.iter().map(|r| r.text.as_str()).collect()
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    #[test]
117    fn test_rich_text_to_plain() {
118        let runs = vec![
119            RichTextRun {
120                text: "Hello ".to_string(),
121                font: None,
122                size: None,
123                bold: true,
124                italic: false,
125                color: None,
126            },
127            RichTextRun {
128                text: "World".to_string(),
129                font: None,
130                size: None,
131                bold: false,
132                italic: false,
133                color: None,
134            },
135        ];
136        assert_eq!(rich_text_to_plain(&runs), "Hello World");
137    }
138
139    #[test]
140    fn test_run_to_xml_plain() {
141        let run = RichTextRun {
142            text: "plain".to_string(),
143            font: None,
144            size: None,
145            bold: false,
146            italic: false,
147            color: None,
148        };
149        let xml_r = run_to_xml(&run);
150        assert!(xml_r.r_pr.is_none());
151        assert_eq!(xml_r.t.value, "plain");
152    }
153
154    #[test]
155    fn test_run_to_xml_bold() {
156        let run = RichTextRun {
157            text: "bold".to_string(),
158            font: None,
159            size: None,
160            bold: true,
161            italic: false,
162            color: None,
163        };
164        let xml_r = run_to_xml(&run);
165        assert!(xml_r.r_pr.is_some());
166        assert!(xml_r.r_pr.as_ref().unwrap().b.is_some());
167    }
168
169    #[test]
170    fn test_xml_to_run_roundtrip() {
171        let original = RichTextRun {
172            text: "test".to_string(),
173            font: Some("Arial".to_string()),
174            size: Some(12.0),
175            bold: true,
176            italic: true,
177            color: Some("#FF0000".to_string()),
178        };
179        let xml_r = run_to_xml(&original);
180        let back = xml_to_run(&xml_r);
181        assert_eq!(original, back);
182    }
183
184    #[test]
185    fn test_runs_to_si() {
186        let runs = vec![
187            RichTextRun {
188                text: "A".to_string(),
189                font: None,
190                size: None,
191                bold: true,
192                italic: false,
193                color: None,
194            },
195            RichTextRun {
196                text: "B".to_string(),
197                font: None,
198                size: None,
199                bold: false,
200                italic: false,
201                color: None,
202            },
203        ];
204        let si = runs_to_si(&runs);
205        assert!(si.t.is_none());
206        assert_eq!(si.r.len(), 2);
207    }
208
209    #[test]
210    fn test_xml_to_run_no_formatting() {
211        let r = R {
212            r_pr: None,
213            t: T {
214                xml_space: None,
215                value: "text".to_string(),
216            },
217        };
218        let run = xml_to_run(&r);
219        assert_eq!(run.text, "text");
220        assert!(!run.bold);
221        assert!(!run.italic);
222        assert!(run.font.is_none());
223        assert!(run.size.is_none());
224        assert!(run.color.is_none());
225    }
226
227    #[test]
228    fn test_space_preservation() {
229        let run = RichTextRun {
230            text: " leading space".to_string(),
231            font: None,
232            size: None,
233            bold: false,
234            italic: false,
235            color: None,
236        };
237        let xml_r = run_to_xml(&run);
238        assert_eq!(xml_r.t.xml_space, Some("preserve".to_string()));
239    }
240}