Skip to main content

cdx_core/presentation/
notes.rs

1//! Footnote and endnote configuration.
2
3use serde::{Deserialize, Serialize};
4
5use super::Style;
6
7/// Footnotes configuration.
8#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
9#[serde(rename_all = "camelCase")]
10pub struct FootnotesConfig {
11    /// Numbering scheme (e.g., "decimal", "lower-alpha", "lower-roman", "symbols").
12    #[serde(default, skip_serializing_if = "Option::is_none")]
13    pub numbering: Option<String>,
14
15    /// Where footnotes are placed.
16    #[serde(default, skip_serializing_if = "Option::is_none")]
17    pub position: Option<FootnotePosition>,
18
19    /// Separator line configuration.
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub separator: Option<FootnoteSeparator>,
22
23    /// Style for footnote text.
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub style: Option<Style>,
26}
27
28impl FootnotesConfig {
29    /// Create a new footnotes configuration.
30    #[must_use]
31    pub fn new() -> Self {
32        Self {
33            numbering: None,
34            position: None,
35            separator: None,
36            style: None,
37        }
38    }
39
40    /// Set the numbering scheme.
41    #[must_use]
42    pub fn with_numbering(mut self, numbering: impl Into<String>) -> Self {
43        self.numbering = Some(numbering.into());
44        self
45    }
46
47    /// Set the footnote position.
48    #[must_use]
49    pub const fn with_position(mut self, position: FootnotePosition) -> Self {
50        self.position = Some(position);
51        self
52    }
53
54    /// Set the separator configuration.
55    #[must_use]
56    pub fn with_separator(mut self, separator: FootnoteSeparator) -> Self {
57        self.separator = Some(separator);
58        self
59    }
60}
61
62impl Default for FootnotesConfig {
63    fn default() -> Self {
64        Self::new()
65    }
66}
67
68/// Where footnotes are placed in the document.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "camelCase")]
71pub enum FootnotePosition {
72    /// At the bottom of each page.
73    PageBottom,
74    /// At the end of each section.
75    SectionEnd,
76    /// At the end of the document.
77    DocumentEnd,
78}
79
80/// Configuration for the footnote separator line.
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "camelCase")]
83pub struct FootnoteSeparator {
84    /// Width of the separator (e.g., "33%", "100px").
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub width: Option<String>,
87
88    /// Line style (e.g., "solid", "dashed", "dotted").
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub style: Option<String>,
91
92    /// Margin above and below (e.g., "8px").
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub margin: Option<String>,
95}
96
97impl FootnoteSeparator {
98    /// Create a new separator configuration.
99    #[must_use]
100    pub fn new() -> Self {
101        Self {
102            width: None,
103            style: None,
104            margin: None,
105        }
106    }
107
108    /// Set the separator width.
109    #[must_use]
110    pub fn with_width(mut self, width: impl Into<String>) -> Self {
111        self.width = Some(width.into());
112        self
113    }
114
115    /// Set the line style.
116    #[must_use]
117    pub fn with_style(mut self, style: impl Into<String>) -> Self {
118        self.style = Some(style.into());
119        self
120    }
121}
122
123impl Default for FootnoteSeparator {
124    fn default() -> Self {
125        Self::new()
126    }
127}
128
129/// Endnotes configuration.
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(rename_all = "camelCase")]
132pub struct EndnotesConfig {
133    /// Title for the endnotes section.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub title: Option<String>,
136
137    /// Numbering scheme (e.g., "decimal", "lower-roman").
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub numbering: Option<String>,
140
141    /// Whether to restart numbering per chapter.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub per_chapter: Option<bool>,
144}
145
146impl EndnotesConfig {
147    /// Create a new endnotes configuration.
148    #[must_use]
149    pub fn new() -> Self {
150        Self {
151            title: None,
152            numbering: None,
153            per_chapter: None,
154        }
155    }
156
157    /// Set the endnotes section title.
158    #[must_use]
159    pub fn with_title(mut self, title: impl Into<String>) -> Self {
160        self.title = Some(title.into());
161        self
162    }
163
164    /// Set the numbering scheme.
165    #[must_use]
166    pub fn with_numbering(mut self, numbering: impl Into<String>) -> Self {
167        self.numbering = Some(numbering.into());
168        self
169    }
170
171    /// Enable per-chapter numbering.
172    #[must_use]
173    pub const fn per_chapter(mut self) -> Self {
174        self.per_chapter = Some(true);
175        self
176    }
177}
178
179impl Default for EndnotesConfig {
180    fn default() -> Self {
181        Self::new()
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn test_footnotes_config_serde() {
191        let config = FootnotesConfig::new()
192            .with_numbering("lower-roman")
193            .with_position(FootnotePosition::PageBottom)
194            .with_separator(
195                FootnoteSeparator::new()
196                    .with_width("33%")
197                    .with_style("solid"),
198            );
199
200        let json = serde_json::to_string_pretty(&config).unwrap();
201        assert!(json.contains("\"numbering\": \"lower-roman\""));
202        assert!(json.contains("\"position\": \"pageBottom\""));
203
204        let parsed: FootnotesConfig = serde_json::from_str(&json).unwrap();
205        assert_eq!(parsed, config);
206    }
207
208    #[test]
209    fn test_footnote_position_serde() {
210        assert_eq!(
211            serde_json::to_string(&FootnotePosition::PageBottom).unwrap(),
212            "\"pageBottom\""
213        );
214        assert_eq!(
215            serde_json::to_string(&FootnotePosition::SectionEnd).unwrap(),
216            "\"sectionEnd\""
217        );
218        assert_eq!(
219            serde_json::to_string(&FootnotePosition::DocumentEnd).unwrap(),
220            "\"documentEnd\""
221        );
222    }
223
224    #[test]
225    fn test_endnotes_config_serde() {
226        let config = EndnotesConfig::new()
227            .with_title("Notes")
228            .with_numbering("decimal")
229            .per_chapter();
230
231        let json = serde_json::to_string_pretty(&config).unwrap();
232        assert!(json.contains("\"title\": \"Notes\""));
233        assert!(json.contains("\"perChapter\": true"));
234
235        let parsed: EndnotesConfig = serde_json::from_str(&json).unwrap();
236        assert_eq!(parsed, config);
237    }
238
239    #[test]
240    fn test_footnote_separator_serde() {
241        let sep = FootnoteSeparator::new()
242            .with_width("50%")
243            .with_style("dashed");
244        let json = serde_json::to_string(&sep).unwrap();
245        assert!(json.contains("\"width\":\"50%\""));
246
247        let parsed: FootnoteSeparator = serde_json::from_str(&json).unwrap();
248        assert_eq!(parsed, sep);
249    }
250
251    #[test]
252    fn test_footnotes_defaults() {
253        let json = "{}";
254        let config: FootnotesConfig = serde_json::from_str(json).unwrap();
255        assert!(config.numbering.is_none());
256        assert!(config.position.is_none());
257        assert!(config.separator.is_none());
258        assert!(config.style.is_none());
259    }
260
261    #[test]
262    fn test_endnotes_defaults() {
263        let json = "{}";
264        let config: EndnotesConfig = serde_json::from_str(json).unwrap();
265        assert!(config.title.is_none());
266        assert!(config.numbering.is_none());
267        assert!(config.per_chapter.is_none());
268    }
269}