Skip to main content

rdenticon/
lib.rs

1#![allow(
2    clippy::cast_precision_loss,
3    clippy::cast_lossless,
4    clippy::cast_possible_truncation,
5    clippy::cast_sign_loss
6)]
7
8mod config;
9mod hsl;
10
11pub use config::*;
12use hsl::corrected_hsl_to_rgb;
13use ril::prelude::*;
14pub use ril::{self, ImageFormat};
15
16/// Colors used by an identicon.
17struct ColorCandidates {
18    light_gray: Rgba,
19    dark_gray: Rgba,
20    light_color: Rgba,
21    mid_color: Rgba,
22    dark_color: Rgba,
23}
24
25impl ColorCandidates {
26    #[inline]
27    const fn get_from_rotation_index(&self, index: usize) -> Rgba {
28        match index {
29            0 => self.dark_gray,
30            1 => self.mid_color,
31            2 => self.light_gray,
32            3 => self.light_color,
33            _ /* 4 */ => self.dark_color,
34        }
35    }
36}
37
38impl Config {
39    /// Retrieves a hue allowed by the configured hues.
40    pub(crate) fn resolve_hue(&self, hue: f64) -> f64 {
41        if self.hues.is_empty() {
42            hue
43        } else {
44            self.hues[(hue / 360.0 * self.hues.len() as f64) as usize]
45        }
46    }
47
48    /// Retrieves a color lightness that conforms to the configured lightness range. The lightness
49    /// is expected to be in the range `[0.0, 1.0]`.
50    #[inline]
51    pub(crate) fn resolve_color_lightness(&self, lightness: f64) -> f64 {
52        (self.color_lightness.end() - self.color_lightness.start())
53            .mul_add(lightness, *self.color_lightness.start())
54    }
55
56    /// Retrieves a grayscale lightness that conforms to the configured lightness range. The
57    /// lightness is expected to be in the range `[0.0, 1.0]`.
58    #[inline]
59    pub(crate) fn resolve_grayscale_lightness(&self, lightness: f64) -> f64 {
60        (self.grayscale_lightness.end() - self.grayscale_lightness.start())
61            .mul_add(lightness, *self.grayscale_lightness.start())
62    }
63
64    /// Retrieves a set of color candidates that conform to this configuration.
65    pub(crate) fn color_candidates(&self, hue: f64) -> ColorCandidates {
66        let hue = self.resolve_hue(hue);
67
68        macro_rules! resolve {
69            ($s:ident, $l_meth:ident, $l_value:literal) => {{
70                corrected_hsl_to_rgb(hue, self.$s, self.$l_meth($l_value)).into_rgba()
71            }};
72            (@grayscale $l_value:literal) => {{
73                resolve!(grayscale_saturation, resolve_grayscale_lightness, $l_value)
74            }};
75            (@color $l_value:literal) => {{
76                resolve!(color_saturation, resolve_color_lightness, $l_value)
77            }};
78        }
79
80        ColorCandidates {
81            light_gray: resolve!(@grayscale 1.0),
82            dark_gray: resolve!(@grayscale 0.0),
83            light_color: resolve!(@color 1.0),
84            mid_color: resolve!(@color 0.5),
85            dark_color: resolve!(@color 0.0),
86        }
87    }
88}
89
90// (x, y, size, rotation)
91#[derive(Copy, Clone, Default)]
92struct Transform {
93    x: u32,
94    y: u32,
95    pub rotation: u8,
96    right: u32,
97    bottom: u32,
98}
99
100impl Transform {
101    pub(crate) const fn new(x: u32, y: u32, size: u32, rotation: u8) -> Self {
102        Self {
103            x,
104            y,
105            rotation,
106            right: x + size,
107            bottom: y + size,
108        }
109    }
110
111    pub(crate) const fn transform(&self, (x, y): (u32, u32), (w, h): (u32, u32)) -> (u32, u32) {
112        match self.rotation {
113            0 => (self.x + x, self.y + y),
114            1 => (self.right - y - h, self.y + x),
115            2 => (self.right - x - w, self.bottom - y - h),
116            _ /* 3 */ => (self.x + y, self.bottom - x - w),
117        }
118    }
119}
120
121struct ShapeRenderer<'a> {
122    image: &'a mut Image<Rgba>,
123    pub current_transform: Transform,
124}
125
126impl<'a> ShapeRenderer<'a> {
127    pub fn new(image: &'a mut Image<Rgba>) -> Self {
128        Self {
129            image,
130            current_transform: Transform::default(),
131        }
132    }
133
134    pub fn polygon(
135        &mut self,
136        color: Rgba,
137        points: impl IntoIterator<Item = (u32, u32)>,
138    ) -> &mut Self {
139        let polygon = Polygon::from_vertices(
140            points
141                .into_iter()
142                .map(|pos| self.current_transform.transform(pos, (0, 0))),
143        )
144        .with_fill(color);
145
146        self.image.draw(&polygon);
147        self
148    }
149
150    pub fn circle(&mut self, color: Rgba, top_left: (u32, u32), diameter: u32) -> &mut Self {
151        let (x, y) = self
152            .current_transform
153            .transform(top_left, (diameter, diameter));
154        let circle = Ellipse::from_bounding_box(x, y, x + diameter, y + diameter).with_fill(color);
155
156        self.image.draw(&circle);
157        self
158    }
159
160    // top left is top left of the bounding box
161    // this creates a right triangle
162    pub fn triangle<const ROTATION: usize>(
163        &mut self,
164        color: Rgba,
165        (x, y): (u32, u32),
166        (w, h): (u32, u32),
167    ) -> &mut Self {
168        let (a, b, c, d) = ((x + w, y), (x + w, y + h), (x, y + h), (x, y));
169        let points = match ROTATION % 4 {
170            0 => [b, c, d],
171            1 => [a, c, d],
172            2 => [a, b, d],
173            3 => [a, b, c],
174            // SAFETY: `rotation % 4` on an unsigned int is always in the range `[0, 3]`.
175            _ => unsafe { std::hint::unreachable_unchecked() },
176        };
177
178        self.polygon(color, points);
179        self
180    }
181
182    pub fn rectangle(
183        &mut self,
184        color: Rgba,
185        top_left: (u32, u32),
186        mut size: (u32, u32),
187    ) -> &mut Self {
188        let (x, y) = self.current_transform.transform(top_left, size);
189        if self.current_transform.rotation & 1 == 1 {
190            std::mem::swap(&mut size.0, &mut size.1);
191        }
192
193        let rect = Rectangle::new()
194            .with_position(x, y)
195            .with_size(size.0, size.1 + 1)
196            .with_fill(color);
197
198        self.image.draw(&rect);
199        self
200    }
201
202    // top left is top left of the bounding box
203    pub fn rhombus(&mut self, color: Rgba, top_left: (u32, u32), size: (u32, u32)) -> &mut Self {
204        self.polygon(
205            color,
206            [
207                (top_left.0 + size.0 / 2, top_left.1),
208                (top_left.0 + size.0, top_left.1 + size.1 / 2),
209                (top_left.0 + size.0 / 2, top_left.1 + size.1),
210                (top_left.0, top_left.1 + size.1 / 2),
211            ],
212        )
213    }
214}
215
216#[inline]
217fn pad_zeroes<const FROM: usize, const TO: usize>(arr: [u8; FROM]) -> [u8; TO] {
218    let mut b = [0; TO];
219    b[TO - FROM..].copy_from_slice(&arr);
220    b
221}
222
223fn into_nibbles(hash: [u8; 20]) -> [u8; 40] {
224    let mut nibbles = [0; 40];
225    for i in 0..20 {
226        nibbles[i * 2] = hash[i] >> 4;
227        nibbles[i * 2 + 1] = hash[i] & 0x0f;
228    }
229    nibbles
230}
231
232#[inline]
233fn hash_substring_u32<const LEN: usize>(nibbles: &[u8; 40], start: usize) -> u32 {
234    let nibbles = pad_zeroes::<LEN, 8>(unsafe {
235        // SAFETY: The hash is always 20 bytes long
236        nibbles[start..start + LEN].try_into().unwrap_unchecked()
237    });
238    // Join nibbles back into bytes
239    let mut bytes = [0; 4];
240    for i in 0..4 {
241        bytes[i] = nibbles[i * 2] << 4 | nibbles[i * 2 + 1];
242    }
243
244    u32::from_be_bytes(bytes)
245}
246
247#[allow(clippy::too_many_arguments)]
248fn render_shape(
249    hash: &[u8; 40],
250    shape_index: usize,
251    rotation_index: Option<usize>,
252    renderer: &mut ShapeRenderer,
253    color: Rgba,
254    background_color: Rgba,
255    cell_offset: u32,
256    cell_size: u32,
257    render_fn: impl Fn(&mut ShapeRenderer, Rgba, Rgba, u32, u8, usize),
258    render_positions: impl IntoIterator<Item = (u32, u32)>,
259) {
260    let mut rotation = rotation_index.map(|idx| hash[idx]).unwrap_or_default();
261    let shape_index = hash[shape_index];
262
263    render_positions
264        .into_iter()
265        .enumerate()
266        .for_each(|(i, (x, y))| {
267            renderer.current_transform = Transform::new(
268                cell_offset + x * cell_size,
269                cell_offset + y * cell_size,
270                cell_size,
271                rotation % 4,
272            );
273            rotation += 1;
274
275            render_fn(renderer, color, background_color, cell_size, shape_index, i);
276        });
277}
278
279fn render_outer(
280    renderer: &mut ShapeRenderer,
281    color: Rgba,
282    _background_color: Rgba,
283    cell_size: u32,
284    shape_index: u8,
285    _position_index: usize,
286) {
287    match shape_index % 4 {
288        0 => renderer.triangle::<0>(color, (0, 0), (cell_size, cell_size)),
289        1 => renderer.triangle::<0>(color, (0, cell_size / 2), (cell_size, cell_size / 2)),
290        2 => renderer.rhombus(color, (0, 0), (cell_size, cell_size)),
291        _ /* 3 */ => {
292            let m = cell_size / 6;
293            renderer.circle(color, (m, m), cell_size - 2 * m)
294        },
295    };
296}
297
298#[allow(clippy::too_many_lines)]
299fn render_center(
300    renderer: &mut ShapeRenderer,
301    color: Rgba,
302    background_color: Rgba,
303    cell_size: u32,
304    shape_index: u8,
305    position_index: usize,
306) {
307    match shape_index % 14 {
308        0 => {
309            let k = (cell_size as f64 * 0.42) as u32;
310            renderer.polygon(
311                color,
312                [
313                    (0, 0),
314                    (cell_size, 0),
315                    (cell_size, cell_size - k * 2),
316                    (cell_size - k, cell_size),
317                    (0, cell_size),
318                ],
319            );
320        }
321        1 => {
322            let w = cell_size / 2;
323            let h = (cell_size as f64 * 0.8) as u32;
324
325            renderer.triangle::<2>(color, (cell_size - w, 0), (w, h));
326        }
327        2 => {
328            let w = cell_size / 3;
329            let dw = cell_size - w;
330
331            renderer.rectangle(color, (w, w), (dw, dw));
332        }
333        3 => {
334            let inner = cell_size as f64 / 10.0;
335            // "Use fixed outer border widths in small icons to ensure the border is drawn"
336            // https://github.com/dmester/jdenticon/blob/master/src/renderer/shapes.js#L41
337            let outer = if cell_size < 6 {
338                1
339            } else if cell_size < 8 {
340                2
341            } else {
342                cell_size / 4
343            };
344
345            let inner = if inner > 1.0 { inner as u32 } else { 1 };
346            let p = cell_size - inner - outer;
347
348            renderer.rectangle(color, (outer, outer), (p, p));
349        }
350        4 => {
351            let m = (cell_size as f64 * 0.15) as u32;
352            let w = cell_size / 2;
353            let p = cell_size - w - m;
354
355            renderer.circle(color, (p, p), w);
356        }
357        5 => {
358            let inner = cell_size / 10;
359            let outer = (cell_size as f64 * 0.4) as u32;
360
361            renderer
362                .rectangle(color, (0, 0), (cell_size, cell_size))
363                .polygon(
364                    background_color,
365                    [
366                        (outer, outer),
367                        (cell_size - inner, outer),
368                        (outer + (cell_size - outer - inner) / 2, cell_size - inner),
369                    ],
370                );
371        }
372        6 => {
373            let tenth = cell_size / 10;
374            let four_tenths = tenth * 4;
375            let seven_tenths = tenth * 7;
376
377            renderer.polygon(
378                color,
379                [
380                    (0, 0),
381                    (cell_size, 0),
382                    (cell_size, seven_tenths),
383                    (four_tenths, four_tenths),
384                    (seven_tenths, cell_size),
385                    (0, cell_size),
386                ],
387            );
388        }
389        7 | 11 => {
390            let half_cell = cell_size / 2;
391            let diff = cell_size - half_cell;
392            renderer.triangle::<3>(color, (half_cell, half_cell), (diff, diff));
393        }
394        8 => {
395            let half_cell = cell_size / 2;
396            let diff = cell_size - half_cell;
397
398            renderer
399                .rectangle(color, (0, 0), (cell_size, diff))
400                .rectangle(color, (0, half_cell), (diff, diff))
401                .triangle::<1>(color, (half_cell, half_cell), (diff, diff));
402        }
403        9 => {
404            let inner = (cell_size as f64 * 0.14) as u32;
405            let outer = if cell_size < 4 {
406                1
407            } else if cell_size < 6 {
408                2
409            } else {
410                (cell_size as f64 * 0.35) as u32
411            };
412
413            let p = cell_size - outer - inner;
414            renderer
415                .rectangle(color, (0, 0), (cell_size, cell_size))
416                .rectangle(background_color, (outer, outer), (p, p));
417        }
418        10 => {
419            let inner = cell_size as f64 * 0.12;
420            let outer = (inner * 3.0) as u32;
421            let inner = inner as u32;
422
423            renderer
424                .rectangle(color, (0, 0), (cell_size, cell_size))
425                .circle(background_color, (outer, outer), cell_size - inner - outer);
426        }
427        12 => {
428            let m = cell_size / 4;
429            let p = cell_size - m;
430
431            renderer
432                .rectangle(color, (0, 0), (cell_size, cell_size))
433                .rectangle(background_color, (m, m), (p, p));
434        }
435        13 if position_index == 0 => {
436            let fcell = cell_size as f64;
437            let m = (fcell * 0.4) as u32;
438            let w = (fcell * 1.2) as u32;
439
440            renderer.circle(color, (m, m), w);
441        }
442        _ => (),
443    }
444}
445
446/// Renders an identicon for the given hash. The hash is strictly 20-bytes long. If your hash is
447/// shorter, you should pad it. Similarly, if your hash is longer, you should truncate it.
448///
449/// # Returns
450/// A ril [`Image`] with the identicon rendered on it. See [`Image::save_inferred`] to save the
451/// image to a file, and similarly [`Image::encode`] to encode the image to a buffer in memory.
452///
453/// Saving identicons to different encodings require different features to be enabled. By default,
454/// rdenticon enables the `ril/png` feature. If, for example, I wanted to save identicons as JPEGs,
455/// I would enable the `ril/jpeg` feature. See the [`ril`] crate for more information on features.
456pub fn render_identicon(hash: [u8; 20], config: &Config) -> Image<Rgba> {
457    const SIDE_POSITIONS: [(u32, u32); 8] = [
458        (1, 0),
459        (2, 0),
460        (2, 3),
461        (1, 3),
462        (0, 1),
463        (3, 1),
464        (3, 2),
465        (0, 2),
466    ];
467    const CORNER_POSITIONS: [(u32, u32); 4] = [(0, 0), (3, 0), (3, 3), (0, 3)];
468    const CENTER_POSITIONS: [(u32, u32); 4] = [(1, 1), (2, 1), (2, 2), (1, 2)];
469
470    let mut image = Image::new(config.size, config.size, config.background_color);
471
472    let padding = (config.padding * config.size as f64).round() as u32;
473    let size = config.size - padding * 2;
474
475    let cell = size / 4;
476    let offset = padding + size / 2 - cell * 2;
477
478    let hash = into_nibbles(hash);
479    let hue = 360.0 * hash_substring_u32::<7>(&hash, 33) as f64 / 0xfffffff as f64;
480    let color_candidates = config.color_candidates(hue);
481
482    let mut selected_indices = [!0; 3];
483    // `.contains` optimization
484    macro_rules! contains_opt {
485        ($value:literal) => {{
486            selected_indices[0] == $value
487                || selected_indices[1] == $value
488                || selected_indices[2] == $value
489        }};
490    }
491
492    for i in 0..3 {
493        let index = hash[i + 8] % 5;
494        let index = match index {
495            0 | 4 if contains_opt!(0) || contains_opt!(4) => 1,
496            2 | 3 if contains_opt!(2) || contains_opt!(3) => 1,
497            _ => index,
498        };
499
500        selected_indices[i] = index;
501    }
502
503    let [side_color, corner_color, center_color] = selected_indices;
504    let (side_color, corner_color, center_color) = (
505        color_candidates.get_from_rotation_index(side_color as usize),
506        color_candidates.get_from_rotation_index(corner_color as usize),
507        color_candidates.get_from_rotation_index(center_color as usize),
508    );
509
510    let mut renderer = ShapeRenderer::new(&mut image);
511    macro_rules! render {
512        (
513            $shape_index:literal,
514            $rotation_index:expr,
515            $color:ident,
516            $render_fn:ident,
517            $render_positions:ident
518        ) => {
519            render_shape(
520                &hash,
521                $shape_index,
522                $rotation_index,
523                &mut renderer,
524                $color,
525                config.background_color,
526                offset,
527                cell,
528                $render_fn,
529                $render_positions,
530            );
531        };
532    }
533
534    render!(2, Some(3), side_color, render_outer, SIDE_POSITIONS);
535    render!(4, Some(5), corner_color, render_outer, CORNER_POSITIONS);
536    render!(1, None, center_color, render_center, CENTER_POSITIONS);
537
538    image
539}
540
541/// Generates an identicon for the given message. The message can be something like a username or a
542/// unique key.
543///
544/// # Note
545/// Identicons are hashed with SHA-1, which is not cryptographically secure. If you need a secure
546/// hash (or if you simply do not want to use SHA-1), generate a hash of 20 bytes with a separate
547/// algorithm and pass those bytes manually to [`render_identicon`].
548///
549/// # Returns
550/// A ril [`Image`] with the identicon rendered on it. See [`Image::save_inferred`] to save the
551/// image to a file, and similarly [`Image::encode`] to encode the image to a buffer in memory.
552///
553/// Saving identicons to different encodings require different features to be enabled. By default,
554/// rdenticon enables the `ril/png` feature. If, for example, I wanted to save identicons as JPEGs,
555/// I would enable the `ril/jpeg` feature. See the [`ril`] crate for more information on features.
556///
557/// # Example
558/// ```no_run
559/// fn main() -> rdenticon::ril::Result<()> {
560///     // Build configuration
561///     let config = rdenticon::Config::builder()
562///         .size(512) // Generate a 512x512 image
563///         .padding(0.1) // Add a 10% padding
564///         .background_color(rdenticon::Rgba::transparent()) // Make the background transparent
565///         .build()
566///         .expect("invalid config");
567///
568///     // Render the identicon
569///     let image = rdenticon::generate_identicon("super-cool-username", &config);
570///
571///     // Save the identicon to a file
572///     image.save_inferred("identicon.png")?;
573///
574///     // OR: Save the identicon to memory
575///     let mut out = Vec::new();
576///     image.encode(rdenticon::ImageFormat::Png, &mut out)?;
577///
578///     Ok(())
579/// }
580/// ```
581pub fn generate_identicon(message: impl AsRef<str>, config: &Config) -> Image<Rgba> {
582    let hash = sha1_smol::Sha1::from(message.as_ref()).digest().bytes();
583    render_identicon(hash, config)
584}
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589
590    #[test]
591    fn test_rdenticon() -> ril::Result<()> {
592        // Build configuration
593        let config = Config::builder()
594            .size(512) // Generate a 512x512 image
595            .padding(0.1) // Add a 10% padding
596            .background_color(Rgba::white())
597            .build()
598            .expect("invalid config");
599
600        let image = generate_identicon("sample", &config);
601        image.save_inferred("identicon.png")
602    }
603}