Skip to main content

img_gen_spec/validators/layers/typography/
line.rs

1use std::num::NonZeroI32;
2
3#[cfg(feature = "pyo3")]
4use pyo3::prelude::*;
5
6use serde::{Deserialize, Serialize};
7
8use crate::{ImgGenSpecError, Result};
9
10/// A custom type to ensure the minimum number of lines is a positive, non-zero [`f32`].
11#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
12pub struct LineHeight(f32);
13
14impl Default for LineHeight {
15    fn default() -> Self {
16        Self(1.0)
17    }
18}
19
20impl LineHeight {
21    /// Creates a line-height ratio where `height` is not equal to zero.
22    pub fn new(height: f32) -> Option<Self> {
23        if height == 0.0 {
24            None
25        } else {
26            Some(Self(height))
27        }
28    }
29
30    /// Returns the validated line-height ratio.
31    pub fn get(&self) -> f32 {
32        self.0
33    }
34
35    /// Deserializes a non-zero line-height ratio.
36    pub fn deserialize<'de, D>(deserializer: D) -> std::result::Result<Self, D::Error>
37    where
38        D: serde::Deserializer<'de>,
39    {
40        use serde::Deserialize;
41
42        let val = f32::deserialize(deserializer)?;
43        Self::new(val).ok_or_else(|| {
44            serde::de::Error::custom(format!("LineHeight must not be zero, got {val}"))
45        })
46    }
47}
48
49/// A property to implicitly describe the size of the text in a
50/// [`Typography`](struct@super::Typography) attribute.
51#[cfg_attr(feature = "pyo3", pyclass(module = "img_gen", from_py_object))]
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct Line {
54    /// The maximum number of lines in the layer.
55    ///
56    /// This value shall not be less than or equal to zero.
57    #[serde(default = "Line::default_line_amount")]
58    pub amount: NonZeroI32,
59    /// The height ratio of each line in the layer.
60    ///
61    /// This value shall not be less than or equal to zero.
62    #[serde(
63        default = "LineHeight::default",
64        deserialize_with = "LineHeight::deserialize"
65    )]
66    pub height: LineHeight,
67}
68
69impl Line {
70    const fn default_line_amount() -> NonZeroI32 {
71        #[allow(clippy::unwrap_used, reason = "1 != 0, and this is a const fn")]
72        NonZeroI32::new(1).unwrap()
73    }
74
75    /// Calculate the font size given the max `height` bound.
76    pub fn get_font_size(&self, height: u32, border_width: Option<u32>) -> Result<u32> {
77        if height == 0 {
78            return Err(ImgGenSpecError::InvalidLayerHeight);
79        }
80        let available_height = height.saturating_sub(border_width.unwrap_or_default()) as f32;
81        let max_size = available_height / (self.amount.get() as f32 * self.height.get());
82        Ok(max_size.max(1.0) as u32)
83    }
84}
85
86impl Default for Line {
87    fn default() -> Self {
88        Self {
89            amount: Self::default_line_amount(),
90            height: LineHeight::default(),
91        }
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    #![allow(clippy::unwrap_used)]
98
99    use super::*;
100
101    #[test]
102    fn test_line_height() {
103        assert!(LineHeight::new(1.0).is_some());
104        assert!(LineHeight::new(0.0).is_none());
105        assert!(LineHeight::new(-1.0).is_some());
106    }
107
108    #[test]
109    fn test_line_font_size() {
110        let line = Line {
111            amount: NonZeroI32::new(2).unwrap(),
112            height: LineHeight(1.5),
113        };
114        let err = line.get_font_size(0, Some(10)).unwrap_err();
115        assert!(matches!(err, ImgGenSpecError::InvalidLayerHeight));
116    }
117
118    #[test]
119    fn deserialize_bad_line_height() {
120        let json = r#"
121        {
122            "amount": 2,
123            "height": 0.0
124        }
125        "#;
126        let err = serde_json::from_str::<Line>(json).unwrap_err();
127        assert!(err.to_string().contains("LineHeight must not be zero"));
128    }
129}