Skip to main content

easyofd_core/
ofd_model.rs

1//! The `OfdModel` trait — the core abstraction for OFD document mapping.
2
3use crate::error::OfdResult;
4use crate::model::OfdPage;
5
6/// Metadata about a column/field in an OFD model, used for declarative mapping.
7#[derive(Debug, Clone)]
8pub struct OfdField {
9    /// Field name (Rust field name).
10    pub name: &'static str,
11    /// Display position (x, y) in mm.
12    pub position: (f64, f64),
13    /// Font family.
14    pub font: &'static str,
15    /// Font size in pt.
16    pub size: f64,
17    /// Font weight (400 = normal, 700 = bold).
18    pub weight: u32,
19    /// Whether italic.
20    pub italic: bool,
21    /// Text color as RGB hex.
22    pub color: u32,
23    /// Field kind for rendering.
24    pub kind: OfdFieldKind,
25}
26
27/// The kind of an OFD field — determines how it is rendered.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum OfdFieldKind {
30    /// Render as text.
31    Text,
32    /// Render as an image (field value must be `Vec<u8>`).
33    Image,
34}
35
36/// The `OfdModel` trait defines the contract for Rust types that can be
37/// mapped to OFD page content.
38///
39/// This is the OFD equivalent of `ExcelRow` in easyexcel-rs.
40/// Derive it with `#[derive(OfdModel)]` for automatic implementation.
41pub trait OfdModel: Sized {
42    /// Returns the field schema for this model.
43    fn schema() -> &'static [OfdField];
44
45    /// Returns the page size (width, height) in mm.
46    fn page_size() -> (f64, f64) {
47        (210.0, 297.0) // A4 default
48    }
49
50    /// Convert this model instance into an OFD page.
51    ///
52    /// # Errors
53    ///
54    /// Returns an error if a field cannot be converted to a page content object.
55    fn to_page(&self) -> OfdResult<OfdPage>;
56
57    /// Convert a slice of model instances into a vec of OFD pages.
58    ///
59    /// # Errors
60    ///
61    /// Returns an error if any item fails conversion via [`to_page`](OfdModel::to_page).
62    fn to_pages(items: &[Self]) -> OfdResult<Vec<OfdPage>> {
63        items.iter().map(Self::to_page).collect()
64    }
65}
66
67#[cfg(test)]
68mod tests {
69    use super::*;
70    use crate::error::OfdError;
71    use crate::model::{ContentObject, TextObject};
72
73    /// A minimal test model implementing `OfdModel` manually.
74    struct TestModel {
75        text: String,
76    }
77
78    impl OfdModel for TestModel {
79        fn schema() -> &'static [OfdField] {
80            &[OfdField {
81                name: "text",
82                position: (10.0, 20.0),
83                font: "SimSun",
84                size: 12.0,
85                weight: 400,
86                italic: false,
87                color: 0,
88                kind: OfdFieldKind::Text,
89            }]
90        }
91
92        fn to_page(&self) -> OfdResult<OfdPage> {
93            let mut page = OfdPage::new(210.0, 297.0);
94            page.add_text(TextObject::new(10.0, 20.0, &self.text));
95            Ok(page)
96        }
97    }
98
99    /// A model whose `to_page` always fails.
100    struct FailingModel;
101
102    impl OfdModel for FailingModel {
103        fn schema() -> &'static [OfdField] {
104            &[]
105        }
106        fn to_page(&self) -> OfdResult<OfdPage> {
107            Err(OfdError::Model("deliberate failure".into()))
108        }
109    }
110
111    #[test]
112    fn test_ofd_field_clone_debug() {
113        let f = OfdField {
114            name: "x",
115            position: (1.0, 2.0),
116            font: "f",
117            size: 10.0,
118            weight: 400,
119            italic: false,
120            color: 0,
121            kind: OfdFieldKind::Text,
122        };
123        let f2 = f.clone();
124        assert_eq!(f2.name, "x");
125        assert!(format!("{f:?}").contains("OfdField"));
126    }
127
128    #[test]
129    fn test_ofd_field_kind_clone_copy_debug() {
130        let k = OfdFieldKind::Image;
131        let k2 = k;
132        assert_eq!(k2, OfdFieldKind::Image);
133        assert_ne!(OfdFieldKind::Text, OfdFieldKind::Image);
134        assert!(format!("{k:?}").contains("Image"));
135    }
136
137    #[test]
138    fn test_ofd_model_default_page_size() {
139        // TestModel doesn't override page_size, so it gets the default A4.
140        assert_eq!(TestModel::page_size(), (210.0, 297.0));
141    }
142
143    #[test]
144    fn test_ofd_model_to_page() {
145        let m = TestModel {
146            text: "hello".into(),
147        };
148        let page = m.to_page().unwrap();
149        assert!((page.width - 210.0).abs() < f64::EPSILON);
150        assert!((page.height - 297.0).abs() < f64::EPSILON);
151        assert_eq!(page.content.len(), 1);
152        assert!(matches!(&page.content[0], ContentObject::Text(t) if t.text == "hello"));
153    }
154
155    #[test]
156    fn test_ofd_model_to_pages() {
157        let items = vec![
158            TestModel { text: "a".into() },
159            TestModel { text: "b".into() },
160        ];
161        let pages = TestModel::to_pages(&items).unwrap();
162        assert_eq!(pages.len(), 2);
163    }
164
165    #[test]
166    fn test_ofd_model_to_pages_empty() {
167        let items: Vec<TestModel> = vec![];
168        let pages = TestModel::to_pages(&items).unwrap();
169        assert!(pages.is_empty());
170    }
171
172    #[test]
173    fn test_ofd_model_to_pages_error() {
174        let items = vec![FailingModel];
175        let result = FailingModel::to_pages(&items);
176        assert!(result.is_err());
177    }
178
179    #[test]
180    fn test_ofd_model_schema() {
181        let schema = TestModel::schema();
182        assert_eq!(schema.len(), 1);
183        assert_eq!(schema[0].name, "text");
184    }
185
186    #[test]
187    fn test_failing_model_schema() {
188        let schema = FailingModel::schema();
189        assert!(schema.is_empty());
190    }
191}