typistapp/
element.rs

1use ab_glyph::{Font, FontArc, PxScale};
2use anyhow::{Result, anyhow};
3use image::{DynamicImage, GenericImageView};
4use log;
5
6use crate::color::Color;
7use crate::{F64_ALMOST_ZERO, FULL_WIDTH_SPACE, IMAGE_SIZE};
8
9/// Represents either a character or image tile, along with its
10/// luminance and pixel characteristics used for comparison and matching.
11#[derive(Debug, Clone, Default, PartialEq)]
12pub struct Element {
13    characteristics: Vec<f64>,
14    luminance: f64,
15    character: Option<char>,
16    image: Option<DynamicImage>,
17}
18
19impl Element {
20    /// Constructs a new Element with the given characteristics and metadata.
21    pub fn new(
22        characteristics: Vec<f64>,
23        luminance: f64,
24        character: Option<char>,
25        image: Option<DynamicImage>,
26    ) -> Self {
27        Element {
28            characteristics,
29            luminance,
30            character,
31            image,
32        }
33    }
34
35    /// Returns the pixel intensity values of the element.
36    pub fn characteristics(&self) -> &[f64] {
37        &self.characteristics
38    }
39
40    /// Returns the average luminance of the element.
41    pub fn luminance(&self) -> f64 {
42        self.luminance
43    }
44
45    /// Returns the character associated with this element, if any.
46    pub fn character(&self) -> Option<char> {
47        self.character
48    }
49
50    /// Returns a reference to the image of this element, if available.
51    #[allow(dead_code)]
52    pub fn image(&self) -> Option<&DynamicImage> {
53        self.image.as_ref()
54    }
55
56    /// Creates an element by rendering a character into an image using the provided font and scale,
57    /// then converting it into luminance data.
58    pub fn from_char(font: &FontArc, character: char, scale: PxScale) -> Result<Self> {
59        let (width, height) = (IMAGE_SIZE, IMAGE_SIZE);
60        let mut characteristics = vec![1.0; (width * height) as usize];
61
62        let glyph = font.glyph_id(character).with_scale(scale);
63        let outline = match font.outline_glyph(glyph) {
64            Some(g) => g,
65            None => {
66                if character == FULL_WIDTH_SPACE {
67                    return Ok(Element {
68                        characteristics,
69                        luminance: 1.0,
70                        character: Some(' '),
71                        image: None,
72                    });
73                }
74                return Err(anyhow!(
75                    "Failed to outline glyph for character: {}",
76                    character
77                ));
78            }
79        };
80
81        let bounds = outline.px_bounds();
82
83        // canvas center - glyph center
84        let glyph_center_x = bounds.min.x + bounds.width() / 2.0;
85        let glyph_center_y = bounds.min.y + bounds.height() / 2.0;
86
87        let canvas_center_x = width as f32 / 2.0;
88        let canvas_center_y = height as f32 / 2.0;
89
90        let offset_x = canvas_center_x - glyph_center_x;
91        let offset_y = canvas_center_y - glyph_center_y;
92
93        outline.draw(|x, y, c| {
94            let canvas_x = x as f32 + offset_x;
95            let canvas_y = y as f32 + offset_y;
96
97            if canvas_x >= 0.0
98                && canvas_x < width as f32
99                && canvas_y >= 0.0
100                && canvas_y < height as f32
101            {
102                let index = (canvas_y as u32 * width + canvas_x as u32) as usize;
103                characteristics[index] = 1.0 - (c as f64);
104            }
105        });
106
107        let luminance = characteristics.iter().sum::<f64>() / (width * height) as f64;
108
109        log::debug!(
110            "Character: '{character}', Width: {width}, Height: {height}, Luminance: {luminance}"
111        );
112
113        Ok(Element {
114            characteristics,
115            luminance,
116            character: Some(character),
117            image: None,
118        })
119    }
120
121    /// Creates an element from an image tile by calculating its luminance characteristics.
122    pub fn from_image(image: DynamicImage) -> Result<Self> {
123        let (width, height) = image.dimensions();
124        log::trace!("Image dimensions: {width}x{height}");
125        if width == 0 || height == 0 {
126            return Err(anyhow!("Image has zero width or height."));
127        }
128
129        let mut characteristics: Vec<f64> = vec![];
130        let mut total_luminance: f64 = 0.0;
131
132        for (_, _, rgba) in image.pixels() {
133            let l = Color::luminance_from_rgba(&rgba.0);
134            total_luminance += l;
135            characteristics.push(l);
136        }
137
138        let luminance = total_luminance / (width * height) as f64;
139
140        Ok(Element {
141            characteristics,
142            luminance,
143            character: None,
144            image: Some(image),
145        })
146    }
147
148    /// Normalizes the element's pixel characteristics and luminance
149    /// to fall within the given luminance range.
150    pub fn normalized(&mut self, min: f64, max: f64) -> Result<()> {
151        if min >= max {
152            return Err(anyhow!(
153                "Invalid range: min ({}) must be less than max ({})",
154                min,
155                max
156            ));
157        }
158
159        log::trace!(
160            "Normalizing element: character: {:?}, luminance: {}",
161            self.character,
162            self.luminance,
163        );
164
165        for value in &mut self.characteristics {
166            *value = Self::normalize(*value, min, max);
167        }
168        self.luminance = Self::normalize(self.luminance, min, max);
169
170        log::trace!(
171            "Normalized element: character: {:?}, luminance: {}",
172            self.character,
173            self.luminance,
174        );
175
176        Ok(())
177    }
178
179    /// Normalizes a single luminance value into the given range.
180    fn normalize(value: f64, min: f64, max: f64) -> f64 {
181        if max - min < F64_ALMOST_ZERO {
182            0.0
183        } else {
184            (value - min) / (max - min)
185        }
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use std::fs;
193
194    const FONT_PATH: &str = "resources/NotoSansJP-Regular.otf";
195
196    #[test]
197    fn element_from_char() {
198        let font_data = fs::read(FONT_PATH).unwrap();
199        let font = FontArc::try_from_vec(font_data).unwrap();
200        let scale = PxScale::from(16.0);
201        let element = Element::from_char(&font, 'A', scale);
202        assert!(element.is_ok());
203        let element = element.unwrap();
204        assert_eq!(element.character, Some('A'));
205        assert!(!element.characteristics.is_empty());
206    }
207
208    #[test]
209    fn normalized_invalid_range_returns_err() {
210        let mut element = Element::new(vec![0.5, 0.6, 0.7], 0.6, Some('A'), None);
211        let result = element.normalized(0.7, 0.5);
212        assert!(result.is_err());
213    }
214
215    #[test]
216    fn normalized_valid_range_returns_ok() {
217        let mut element = Element::new(vec![0.5, 0.6, 0.7], 0.6, Some('A'), None);
218        let result = element.normalized(0.5, 0.7);
219        assert!(result.is_ok());
220        assert_eq!(element.characteristics, vec![0.0, 0.5, 1.0]);
221        assert_eq!(element.luminance, 0.5);
222    }
223}