Skip to main content

cdx_core/presentation/
typography.rs

1//! Typography configuration for advanced text rendering.
2
3use serde::{Deserialize, Serialize};
4
5/// Typography configuration for a presentation layer.
6#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
7#[serde(rename_all = "camelCase")]
8pub struct TypographyConfig {
9    /// Line numbering configuration.
10    #[serde(default, skip_serializing_if = "Option::is_none")]
11    pub line_numbering: Option<LineNumbering>,
12
13    /// Baseline grid alignment.
14    #[serde(default, skip_serializing_if = "Option::is_none")]
15    pub baseline_grid: Option<BaselineGrid>,
16
17    /// Hyphenation settings.
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub hyphenation: Option<HyphenationConfig>,
20}
21
22impl TypographyConfig {
23    /// Create a new empty typography configuration.
24    #[must_use]
25    pub fn new() -> Self {
26        Self {
27            line_numbering: None,
28            baseline_grid: None,
29            hyphenation: None,
30        }
31    }
32
33    /// Set line numbering.
34    #[must_use]
35    pub fn with_line_numbering(mut self, ln: LineNumbering) -> Self {
36        self.line_numbering = Some(ln);
37        self
38    }
39
40    /// Set baseline grid.
41    #[must_use]
42    pub fn with_baseline_grid(mut self, bg: BaselineGrid) -> Self {
43        self.baseline_grid = Some(bg);
44        self
45    }
46
47    /// Set hyphenation.
48    #[must_use]
49    pub fn with_hyphenation(mut self, h: HyphenationConfig) -> Self {
50        self.hyphenation = Some(h);
51        self
52    }
53}
54
55impl Default for TypographyConfig {
56    fn default() -> Self {
57        Self::new()
58    }
59}
60
61/// Line numbering configuration.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "camelCase")]
64pub struct LineNumbering {
65    /// Whether line numbering is enabled.
66    pub enabled: bool,
67
68    /// Starting line number (default: 1).
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub start: Option<u32>,
71
72    /// Show number every N lines (default: 1).
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub interval: Option<u32>,
75
76    /// Which side to display numbers.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub side: Option<LineNumberingSide>,
79
80    /// When to restart numbering.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub restart: Option<LineNumberingRestart>,
83}
84
85impl LineNumbering {
86    /// Create enabled line numbering with defaults.
87    #[must_use]
88    pub fn enabled() -> Self {
89        Self {
90            enabled: true,
91            start: None,
92            interval: None,
93            side: None,
94            restart: None,
95        }
96    }
97
98    /// Set the starting number.
99    #[must_use]
100    pub const fn with_start(mut self, start: u32) -> Self {
101        self.start = Some(start);
102        self
103    }
104
105    /// Set the numbering interval.
106    #[must_use]
107    pub const fn with_interval(mut self, interval: u32) -> Self {
108        self.interval = Some(interval);
109        self
110    }
111}
112
113/// Side for line numbers.
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(rename_all = "lowercase")]
116pub enum LineNumberingSide {
117    /// Display on the left margin.
118    Left,
119    /// Display on the right margin.
120    Right,
121}
122
123/// When to restart line numbering.
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(rename_all = "lowercase")]
126pub enum LineNumberingRestart {
127    /// Restart at each page.
128    Page,
129    /// Restart at each section.
130    Section,
131    /// Never restart (continuous numbering).
132    #[serde(rename = "none")]
133    NoRestart,
134}
135
136/// Baseline grid configuration for vertical rhythm.
137#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
138#[serde(rename_all = "camelCase")]
139pub struct BaselineGrid {
140    /// Whether baseline grid snapping is enabled.
141    pub enabled: bool,
142
143    /// Line height for the grid (e.g., "14pt").
144    pub line_height: String,
145
146    /// Vertical offset from the top (e.g., "10pt").
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub offset: Option<String>,
149}
150
151impl BaselineGrid {
152    /// Create a baseline grid with the given line height.
153    #[must_use]
154    pub fn new(line_height: impl Into<String>) -> Self {
155        Self {
156            enabled: true,
157            line_height: line_height.into(),
158            offset: None,
159        }
160    }
161}
162
163/// Hyphenation configuration.
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165#[serde(rename_all = "camelCase")]
166pub struct HyphenationConfig {
167    /// Whether hyphenation is enabled.
168    pub enabled: bool,
169
170    /// Language for hyphenation rules (BCP 47 tag).
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub language: Option<String>,
173
174    /// Minimum word length to hyphenate.
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub min_word_length: Option<u32>,
177
178    /// Minimum characters before a hyphen.
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub min_before: Option<u32>,
181
182    /// Minimum characters after a hyphen.
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub min_after: Option<u32>,
185
186    /// Maximum consecutive hyphenated lines.
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub max_consecutive: Option<u32>,
189}
190
191impl HyphenationConfig {
192    /// Create enabled hyphenation with defaults.
193    #[must_use]
194    pub fn enabled() -> Self {
195        Self {
196            enabled: true,
197            language: None,
198            min_word_length: None,
199            min_before: None,
200            min_after: None,
201            max_consecutive: None,
202        }
203    }
204
205    /// Set the language.
206    #[must_use]
207    pub fn with_language(mut self, lang: impl Into<String>) -> Self {
208        self.language = Some(lang.into());
209        self
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn test_typography_config_serde() {
219        let config = TypographyConfig::new()
220            .with_line_numbering(LineNumbering::enabled().with_interval(5))
221            .with_hyphenation(HyphenationConfig::enabled().with_language("en-US"));
222
223        let json = serde_json::to_string_pretty(&config).unwrap();
224        let parsed: TypographyConfig = serde_json::from_str(&json).unwrap();
225        assert_eq!(parsed, config);
226    }
227
228    #[test]
229    fn test_line_numbering_serde() {
230        let ln = LineNumbering::enabled().with_start(10).with_interval(5);
231        let json = serde_json::to_string(&ln).unwrap();
232        assert!(json.contains("\"start\":10"));
233        assert!(json.contains("\"interval\":5"));
234
235        let parsed: LineNumbering = serde_json::from_str(&json).unwrap();
236        assert_eq!(parsed, ln);
237    }
238
239    #[test]
240    fn test_line_numbering_side_serde() {
241        let side = LineNumberingSide::Left;
242        let json = serde_json::to_string(&side).unwrap();
243        assert_eq!(json, "\"left\"");
244
245        let right: LineNumberingSide = serde_json::from_str("\"right\"").unwrap();
246        assert_eq!(right, LineNumberingSide::Right);
247    }
248
249    #[test]
250    fn test_line_numbering_restart_serde() {
251        let restart = LineNumberingRestart::NoRestart;
252        let json = serde_json::to_string(&restart).unwrap();
253        assert_eq!(json, "\"none\"");
254
255        let page: LineNumberingRestart = serde_json::from_str("\"page\"").unwrap();
256        assert_eq!(page, LineNumberingRestart::Page);
257    }
258
259    #[test]
260    fn test_baseline_grid_serde() {
261        let grid = BaselineGrid::new("14pt");
262        let json = serde_json::to_string(&grid).unwrap();
263        assert!(json.contains("\"lineHeight\":\"14pt\""));
264
265        let parsed: BaselineGrid = serde_json::from_str(&json).unwrap();
266        assert_eq!(parsed, grid);
267    }
268
269    #[test]
270    fn test_hyphenation_serde() {
271        let hyph = HyphenationConfig::enabled().with_language("de");
272        let json = serde_json::to_string(&hyph).unwrap();
273        assert!(json.contains("\"language\":\"de\""));
274
275        let parsed: HyphenationConfig = serde_json::from_str(&json).unwrap();
276        assert_eq!(parsed, hyph);
277    }
278
279    #[test]
280    fn test_typography_defaults() {
281        let json = "{}";
282        let config: TypographyConfig = serde_json::from_str(json).unwrap();
283        assert!(config.line_numbering.is_none());
284        assert!(config.baseline_grid.is_none());
285        assert!(config.hyphenation.is_none());
286    }
287}