1use super::CertificateData;
2use crate::certificates::error::{CertificateError, Result};
3use ab_glyph::{Font, FontRef, PxScale, ScaleFont};
4use base64::Engine as _;
5use base64::engine::general_purpose;
6use image::{DynamicImage, ImageBuffer, ImageFormat, ImageReader, Luma, Rgba, RgbaImage, imageops};
7use imageproc::drawing::draw_text_mut;
8use imageproc::rect::Rect;
9use lazy_static::lazy_static;
10use plot_icon::generate_png;
11use qrcode::{EcLevel, QrCode, types::QrError as QrCodeError};
12use std::io::Cursor;
13
14const BORDER_COLOR: Rgba<u8> = Rgba([0, 82, 165, 255]); const BACKGROUND_COLOR: Rgba<u8> = Rgba([255, 253, 245, 255]); const TEXT_COLOR: Rgba<u8> = Rgba([51, 51, 51, 255]); const HIGHLIGHT_COLOR: Rgba<u8> = Rgba([0, 121, 193, 255]); const TITLE_FONT_SIZE: f32 = 48.0;
22const BODY_FONT_SIZE: f32 = 24.0;
23const SMALL_FONT_SIZE: f32 = 18.0;
24
25lazy_static! {
26 static ref TITLE_FONT: FontRef<'static> =
27 FontRef::try_from_slice(include_bytes!("../../assets/Montserrat-Bold.ttf"))
28 .expect("Failed to load title font");
29 static ref BODY_FONT: FontRef<'static> =
30 FontRef::try_from_slice(include_bytes!("../../assets/Lora-Regular.ttf"))
31 .expect("Failed to load body font");
32}
33
34pub fn create_certificate(
35 certificate_data: &CertificateData,
36 url: &str,
37 issuer: &str,
38) -> Result<DynamicImage> {
39 let cert_width = 1200;
40 let cert_height = 900;
41 let border_thickness = 30u32;
42 let qr_code_size = 250;
43
44 let qr_code =
46 QrCode::with_error_correction_level(url, EcLevel::H).map_err(|e: QrCodeError| {
47 CertificateError::ImageProcessingError(format!("Failed to create QR code: {}", e))
48 })?;
49
50 let qr_code_image = qr_code.render::<Luma<u8>>().quiet_zone(false).build();
51 let qr_code_image_rgba =
52 ImageBuffer::from_fn(qr_code_image.width(), qr_code_image.height(), |x, y| {
53 let pixel = qr_code_image.get_pixel(x, y);
54 if pixel[0] == 0 {
55 Rgba([0, 0, 0, 255])
56 } else {
57 Rgba([255, 255, 255, 255])
58 }
59 });
60 let resized_qr_code_image = imageops::resize(
61 &qr_code_image_rgba,
62 qr_code_size,
63 qr_code_size,
64 imageops::FilterType::Nearest,
65 );
66
67 let mut cert_image = RgbaImage::new(cert_width, cert_height);
69
70 imageproc::drawing::draw_filled_rect_mut(
72 &mut cert_image,
73 Rect::at(0, 0).of_size(cert_width, cert_height),
74 BORDER_COLOR,
75 );
76 imageproc::drawing::draw_filled_rect_mut(
77 &mut cert_image,
78 Rect::at(border_thickness as i32, border_thickness as i32).of_size(
79 cert_width - 2 * border_thickness,
80 cert_height - 2 * border_thickness,
81 ),
82 BACKGROUND_COLOR,
83 );
84
85 let logo_bytes = include_bytes!("../../assets/favicon.png");
87 let logo_image = ImageReader::new(Cursor::new(logo_bytes))
88 .with_guessed_format()
89 .map_err(|e| {
90 CertificateError::ImageProcessingError(format!(
91 "Failed to guess logo image format: {}",
92 e
93 ))
94 })?
95 .decode()
96 .map_err(|e| {
97 CertificateError::ImageProcessingError(format!("Failed to decode logo image: {}", e))
98 })?;
99
100 let scaled_logo_image = imageops::resize(&logo_image, 120, 120, imageops::FilterType::Lanczos3);
101 image::imageops::overlay(&mut cert_image, &scaled_logo_image, 50, 50);
102
103 let identicon_image = {
105 let data: Vec<u8> =
106 generate_png(certificate_data.to_base64().as_bytes(), 90).map_err(|e| {
107 CertificateError::ImageProcessingError(format!(
108 "Failed to generate identicon: {}",
109 e
110 ))
111 })?;
112
113 let mut image = ImageReader::new(Cursor::new(data));
114 image.set_format(image::ImageFormat::Png);
115
116 image.decode().map_err(|e| {
117 CertificateError::ImageProcessingError(format!("Failed to decode identicon: {}", e))
118 })?
119 };
120
121 image::imageops::overlay(
122 &mut cert_image,
123 &identicon_image,
124 cert_width as i64 - 140,
125 50,
126 );
127
128 let title = "Certificate of Achievement";
130 draw_text_centered(
131 &mut cert_image,
132 &TITLE_FONT,
133 TITLE_FONT_SIZE,
134 title,
135 120,
136 HIGHLIGHT_COLOR,
137 );
138
139 let achievement = format!(
141 "Successfully completed the {} path",
142 certificate_data.game_path_name
143 );
144 draw_text_centered(
145 &mut cert_image,
146 &BODY_FONT,
147 BODY_FONT_SIZE,
148 &achievement,
149 300,
150 TEXT_COLOR,
151 );
152
153 let performance = format!(
155 "Completed {} out of {} challenges with {}% performance",
156 certificate_data.solved_challenges,
157 certificate_data.total_challenges,
158 certificate_data.performance_percentage
159 );
160 draw_text_centered(
161 &mut cert_image,
162 &BODY_FONT,
163 BODY_FONT_SIZE,
164 &performance,
165 350,
166 TEXT_COLOR,
167 );
168
169 let date_str = format!("Issued on {}", certificate_data.date.format("%d %B %Y"));
171 draw_text_centered(
172 &mut cert_image,
173 &BODY_FONT,
174 SMALL_FONT_SIZE,
175 &date_str,
176 460,
177 TEXT_COLOR,
178 );
179
180 let issued_by_message = format!("Issued by {}", issuer);
182 draw_text_centered(
183 &mut cert_image,
184 &BODY_FONT,
185 SMALL_FONT_SIZE,
186 &issued_by_message,
187 510,
188 HIGHLIGHT_COLOR,
189 );
190
191 let qr_code_size = resized_qr_code_image.width() as u32;
193 imageops::overlay(
194 &mut cert_image,
195 &resized_qr_code_image,
196 (cert_width - qr_code_size) as i64 / 2,
197 (cert_height - qr_code_size - 80) as i64, );
199
200 Ok(DynamicImage::ImageRgba8(cert_image))
201}
202
203pub fn calculate_text_width(font: &FontRef, scale: PxScale, text: &str) -> u32 {
204 let scaled_font = font.as_scaled(scale);
205 text.chars().fold(0.0, |acc, c| {
206 acc + scaled_font.h_advance(scaled_font.scaled_glyph(c).id)
207 }) as u32
208}
209
210pub fn create_certificate_data_url(
211 certificate_data: &CertificateData,
212 url: &str,
213 issuer: &str,
214) -> Result<String> {
215 let image = create_certificate(certificate_data, url, issuer)?;
216
217 let mut image_data: Vec<u8> = Vec::new();
218 let mut cursor = Cursor::new(&mut image_data);
219
220 image.write_to(&mut cursor, ImageFormat::Png).map_err(|e| {
221 CertificateError::ImageProcessingError(format!("Failed to write image to buffer: {}", e))
222 })?;
223
224 let res_base64 = general_purpose::STANDARD.encode(image_data);
225 Ok(format!("data:image/png;base64,{}", res_base64))
226}
227
228pub fn draw_text_centered(
229 image: &mut RgbaImage,
230 font: &FontRef,
231 font_size: f32,
232 text: &str,
233 y: u32,
234 color: Rgba<u8>,
235) {
236 let scale = PxScale::from(font_size);
237 let text_width = calculate_text_width(font, scale, text);
238 let x = (image.width() as i32 - text_width as i32) / 2;
239 draw_text_mut(image, color, x, y as i32, scale, font, text);
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245 use chrono::Utc;
246
247 #[test]
248 fn test_create_certificate() {
249 let certificate_data = CertificateData::new(
250 "Test Game Path".to_string(),
251 10,
252 5,
253 "Test Player".to_string(),
254 Utc::now(),
255 );
256 let url = "https://example.com".to_string();
257 let issuer = "Test Issuer";
258
259 let image = create_certificate(&certificate_data, &url, issuer).unwrap();
260
261 assert!(image.width() > 100);
262 assert!(image.height() > 100);
263 }
264
265 #[test]
266 fn test_create_certificate_data_url() {
267 let certificate_data = CertificateData::new(
268 "Test Game Path".to_string(),
269 10,
270 5,
271 "Test Player".to_string(),
272 Utc::now(),
273 );
274 let url = "https://example.com";
275 let issuer = "Test Issuer";
276
277 let data_url = create_certificate_data_url(&certificate_data, url, issuer).unwrap();
278
279 assert!(data_url.starts_with("data:image/png;base64,"));
280 }
281
282 #[test]
283 fn test_error_handling_invalid_url() {
284 let certificate_data = CertificateData::new(
286 "Test Game Path".to_string(),
287 10,
288 5,
289 "Test Player".to_string(),
290 Utc::now(),
291 );
292
293 let url = "a".repeat(2000); let issuer = "Test Issuer";
298
299 let result = create_certificate(&certificate_data, &url, issuer);
300 assert!(result.is_err());
301
302 match result {
304 Err(CertificateError::ImageProcessingError(_)) => {} Err(e) => panic!("Unexpected error type: {:?}", e),
306 Ok(_) => panic!("Expected an error but got success"),
307 }
308 }
309}