Skip to main content

cdx_core/presentation/
paginated.rs

1//! Paginated presentation layer.
2
3use serde::{Deserialize, Serialize};
4
5use super::style::{StyleMap, Transform};
6
7/// Paginated presentation for print/PDF output.
8#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
9pub struct Paginated {
10    /// Format version.
11    pub version: String,
12
13    /// Presentation type (always "paginated").
14    #[serde(rename = "type")]
15    pub presentation_type: String,
16
17    /// Default page settings.
18    pub defaults: PaginatedDefaults,
19
20    /// Page definitions.
21    #[serde(default, skip_serializing_if = "Vec::is_empty")]
22    pub pages: Vec<Page>,
23
24    /// Style definitions.
25    #[serde(default, skip_serializing_if = "StyleMap::is_empty")]
26    pub styles: StyleMap,
27}
28
29impl Default for Paginated {
30    fn default() -> Self {
31        Self {
32            version: crate::SPEC_VERSION.to_string(),
33            presentation_type: "paginated".to_string(),
34            defaults: PaginatedDefaults::default(),
35            pages: Vec::new(),
36            styles: StyleMap::new(),
37        }
38    }
39}
40
41/// Default settings for paginated presentation.
42#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
43#[serde(rename_all = "camelCase")]
44pub struct PaginatedDefaults {
45    /// Page size.
46    pub page_size: PageSize,
47
48    /// Page orientation.
49    pub orientation: Orientation,
50
51    /// Page margins.
52    pub margins: Margins,
53}
54
55impl Default for PaginatedDefaults {
56    fn default() -> Self {
57        Self {
58            page_size: PageSize::letter(),
59            orientation: Orientation::Portrait,
60            margins: Margins::default(),
61        }
62    }
63}
64
65/// Standard page sizes.
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
67#[serde(untagged)]
68pub enum PageSize {
69    /// Named page size.
70    Named(StandardPageSize),
71    /// Custom page size with dimensions.
72    Custom {
73        /// Page width with units (e.g., "8in").
74        width: String,
75        /// Page height with units (e.g., "10in").
76        height: String,
77    },
78}
79
80impl PageSize {
81    /// US Letter size (8.5 x 11 in).
82    #[must_use]
83    pub fn letter() -> Self {
84        Self::Named(StandardPageSize::Letter)
85    }
86
87    /// US Legal size (8.5 x 14 in).
88    #[must_use]
89    pub fn legal() -> Self {
90        Self::Named(StandardPageSize::Legal)
91    }
92
93    /// A4 size (210 x 297 mm).
94    #[must_use]
95    pub fn a4() -> Self {
96        Self::Named(StandardPageSize::A4)
97    }
98
99    /// A5 size (148 x 210 mm).
100    #[must_use]
101    pub fn a5() -> Self {
102        Self::Named(StandardPageSize::A5)
103    }
104}
105
106impl Default for PageSize {
107    fn default() -> Self {
108        Self::letter()
109    }
110}
111
112/// Standard named page sizes.
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(rename_all = "lowercase")]
115pub enum StandardPageSize {
116    /// US Letter (8.5 x 11 in).
117    Letter,
118    /// US Legal (8.5 x 14 in).
119    Legal,
120    /// A4 (210 x 297 mm).
121    A4,
122    /// A5 (148 x 210 mm).
123    A5,
124}
125
126/// Page orientation.
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
128#[serde(rename_all = "lowercase")]
129pub enum Orientation {
130    /// Portrait orientation (taller than wide).
131    #[default]
132    Portrait,
133    /// Landscape orientation (wider than tall).
134    Landscape,
135}
136
137/// Page margins.
138#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
139pub struct Margins {
140    /// Top margin.
141    pub top: String,
142    /// Right margin.
143    pub right: String,
144    /// Bottom margin.
145    pub bottom: String,
146    /// Left margin.
147    pub left: String,
148}
149
150impl Default for Margins {
151    fn default() -> Self {
152        Self {
153            top: "1in".to_string(),
154            right: "1in".to_string(),
155            bottom: "1in".to_string(),
156            left: "1in".to_string(),
157        }
158    }
159}
160
161impl Margins {
162    /// Create margins with all sides equal.
163    #[must_use]
164    pub fn all(value: impl Into<String>) -> Self {
165        let v = value.into();
166        Self {
167            top: v.clone(),
168            right: v.clone(),
169            bottom: v.clone(),
170            left: v,
171        }
172    }
173}
174
175/// A page in a paginated presentation.
176#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
177pub struct Page {
178    /// Page number (1-indexed).
179    pub number: u32,
180
181    /// Elements on this page.
182    #[serde(default)]
183    pub elements: Vec<PageElement>,
184}
185
186/// An element positioned on a page.
187#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
188#[serde(rename_all = "camelCase")]
189pub struct PageElement {
190    /// Reference to the content block ID.
191    pub block_id: String,
192
193    /// Position and size.
194    pub position: Position,
195
196    /// Style name to apply.
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub style: Option<String>,
199
200    /// Overflow behavior.
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub overflow: Option<String>,
203
204    /// 2D transform for rotation, scale, skew.
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub transform: Option<Transform>,
207}
208
209/// Element position on a page.
210#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
211pub struct Position {
212    /// X position from left.
213    pub x: String,
214    /// Y position from top.
215    pub y: String,
216    /// Width (or "auto").
217    pub width: String,
218    /// Height (or "auto").
219    pub height: String,
220}
221
222impl Position {
223    /// Create a position with auto height.
224    #[must_use]
225    pub fn new(x: impl Into<String>, y: impl Into<String>, width: impl Into<String>) -> Self {
226        Self {
227            x: x.into(),
228            y: y.into(),
229            width: width.into(),
230            height: "auto".to_string(),
231        }
232    }
233}
234
235/// Flow element for automatic text flow across pages.
236#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
237#[serde(rename_all = "camelCase")]
238pub struct FlowElement {
239    /// Element type (always "flow").
240    #[serde(rename = "type")]
241    pub element_type: String,
242
243    /// Content block IDs to flow.
244    pub block_ids: Vec<String>,
245
246    /// Number of columns.
247    #[serde(default = "default_columns")]
248    pub columns: u32,
249
250    /// Starting page number.
251    pub start_page: u32,
252
253    /// Flow regions on each page.
254    pub regions: Vec<FlowRegion>,
255}
256
257fn default_columns() -> u32 {
258    1
259}
260
261/// A region where content can flow.
262#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
263pub struct FlowRegion {
264    /// Page number for this region.
265    pub page: u32,
266    /// Position of the region.
267    pub position: Position,
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn test_paginated_default() {
276        let p = Paginated::default();
277        assert_eq!(p.presentation_type, "paginated");
278        assert_eq!(p.defaults.page_size, PageSize::letter());
279        assert_eq!(p.defaults.orientation, Orientation::Portrait);
280    }
281
282    #[test]
283    fn test_margins() {
284        let m = Margins::all("0.5in");
285        assert_eq!(m.top, "0.5in");
286        assert_eq!(m.right, "0.5in");
287        assert_eq!(m.bottom, "0.5in");
288        assert_eq!(m.left, "0.5in");
289    }
290
291    #[test]
292    fn test_serialization() {
293        let p = Paginated::default();
294        let json = serde_json::to_string_pretty(&p).unwrap();
295        assert!(json.contains("\"type\": \"paginated\""));
296        assert!(json.contains("\"pageSize\": \"letter\""));
297    }
298
299    #[test]
300    fn test_custom_page_size() {
301        let size = PageSize::Custom {
302            width: "8in".to_string(),
303            height: "10in".to_string(),
304        };
305        let json = serde_json::to_string(&size).unwrap();
306        assert!(json.contains("\"width\":\"8in\""));
307    }
308}