1#![warn(clippy::all, clippy::pedantic, clippy::nursery)]
2
3pub mod cards;
4pub mod customizations;
5mod font;
6mod toy;
7
8use std::sync::Arc;
9
10use cards::Card;
11pub use font::Font;
12use resvg::usvg::{ImageKind, ImageRendering, TreeParsing, TreeTextToPath};
13use tera::Value;
14pub use toy::Toy;
15
16#[derive(serde::Serialize, Debug, Clone, PartialEq, Eq)]
19pub struct Context {
20 pub level: u64,
22 pub rank: i64,
24 pub name: String,
26 pub discriminator: Option<String>,
28 pub percentage: u64,
30 pub current: u64,
32 pub needed: u64,
34 pub customizations: customizations::Customizations,
36 pub avatar: String,
38}
39
40#[derive(Clone)]
42pub struct SvgState {
43 fonts: Arc<resvg::usvg::fontdb::Database>,
44 tera: Arc<tera::Tera>,
45 threads: Arc<rayon::ThreadPool>,
46}
47
48impl SvgState {
49 #[must_use]
51 pub fn new() -> Self {
52 Self::default()
53 }
54
55 pub async fn render(&self, data: Context) -> Result<Vec<u8>, Error> {
60 let cloned_self = self.clone();
61 let (send, recv) = tokio::sync::oneshot::channel();
62 self.threads.spawn(move || {
63 send.send(cloned_self.sync_render(&data)).ok();
64 });
65 recv.await?
66 }
67
68 pub fn render_svg(&self, context: Context) -> Result<String, Error> {
72 let name = context.customizations.card.name();
73 let ctx = tera::Context::from_serialize(context)?;
74 Ok(self.tera.render(name, &ctx)?)
75 }
76
77 pub fn sync_render(&self, context: &Context) -> Result<Vec<u8>, Error> {
81 let svg = self.tera.render(
82 context.customizations.card.name(),
83 &tera::Context::from_serialize(context)?,
84 )?;
85 let resolve_data = Box::new(
86 |mime: &str, data: std::sync::Arc<Vec<u8>>, _: &resvg::usvg::Options| match mime {
87 "image/png" => Some(ImageKind::PNG(data)),
88 "image/jpg" | "image/jpeg" => Some(ImageKind::JPEG(data)),
89 _ => None,
90 },
91 );
92 let resolve_string = Box::new(move |href: &str, _: &resvg::usvg::Options| {
93 Some(ImageKind::PNG(
94 Toy::from_filename(href)?.png().to_vec().into(),
95 ))
96 });
97 let opt = resvg::usvg::Options {
98 image_href_resolver: resvg::usvg::ImageHrefResolver {
99 resolve_data,
100 resolve_string,
101 },
102 image_rendering: ImageRendering::OptimizeSpeed,
103 font_family: context.customizations.font.to_string(),
104 ..Default::default()
105 };
106 let mut tree = resvg::usvg::Tree::from_str(&svg, &opt)?;
107 tree.convert_text(&self.fonts);
108 let pixmap_size = tree.size.to_int_size();
109 let mut pixmap = resvg::tiny_skia::Pixmap::new(pixmap_size.width(), pixmap_size.height())
110 .ok_or(Error::PixmapCreation)?;
111 let retree = resvg::Tree::from_usvg(&tree);
112 retree.render(resvg::tiny_skia::Transform::default(), &mut pixmap.as_mut());
113 Ok(pixmap.encode_png()?)
114 }
115}
116
117impl Default for SvgState {
118 fn default() -> Self {
119 let mut fonts = resvg::usvg::fontdb::Database::new();
120 fonts.load_font_data(Font::Mojang.ttf().to_vec());
121 fonts.load_font_data(Font::Roboto.ttf().to_vec());
122 fonts.load_font_data(Font::JetBrainsMono.ttf().to_vec());
123 fonts.load_font_data(Font::MontserratAlt1.ttf().to_vec());
124 let mut tera = tera::Tera::default();
125 tera.autoescape_on(vec!["svg", "html", "xml", "htm"]);
126 tera.add_raw_templates([
127 (Card::Classic.name(), Card::Classic.template()),
128 (Card::Vertical.name(), Card::Vertical.template()),
129 ])
130 .expect("Failed to build template");
131 tera.register_filter("integerhumanize", ihumanize);
132 let threads = rayon::ThreadPoolBuilder::new().build().unwrap();
133 Self {
134 fonts: Arc::new(fonts),
135 tera: Arc::new(tera),
136 threads: Arc::new(threads),
137 }
138 }
139}
140
141#[allow(clippy::unnecessary_wraps)]
142fn ihumanize(v: &Value, _hm: &std::collections::HashMap<String, Value>) -> tera::Result<Value> {
143 let num = if let Value::Number(num) = v {
144 if let Some(num) = num.as_f64() {
145 num
146 } else {
147 return Ok(v.clone());
148 }
149 } else {
150 return Ok(v.clone());
151 };
152 let (suffix, xp) = if (1_000.0..1_000_000.0).contains(&num) {
153 ("k", num / 1_000.0)
154 } else if (1_000_000.0..1_000_000_000.0).contains(&num) {
155 ("m", num / 1_000_000.0)
156 } else if (1_000_000_000.0..1_000_000_000_000.0).contains(&num) {
157 ("b", num / 1_000_000_000.0)
158 } else {
159 ("", num)
160 };
161 let xp_untrim = format!("{xp:.1}");
162 let xp_trim = xp_untrim.trim_end_matches(".0");
163 Ok(Value::String(format!("{xp_trim}{suffix}")))
164}
165
166#[derive(Debug, thiserror::Error)]
167pub enum Error {
168 #[error("Tera error: {0}")]
169 Template(#[from] tera::Error),
170 #[error("uSVG error: {0}")]
171 Usvg(#[from] resvg::usvg::Error),
172 #[error("Integer parsing error: {0}!")]
173 ParseInt(#[from] std::num::ParseIntError),
174 #[error("Pixmap error: {0}")]
175 Pixmap(#[from] png::EncodingError),
176 #[error("Rayon error: {0}")]
177 Rayon(#[from] rayon::ThreadPoolBuildError),
178 #[error("Render result fetching error: {0}")]
179 Recv(#[from] tokio::sync::oneshot::error::RecvError),
180 #[error("Pixmap Creation error!")]
181 PixmapCreation,
182 #[error("Invalid length! Color hex data length must be exactly 6 characters!")]
183 InvalidLength,
184}
185
186#[cfg(test)]
187mod tests {
188 const VALK_PFP: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQAAAAEABAMAAACuXLVVAAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAAYUExURXG0zgAAAFdXV6ampoaGhr6zpHxfQ2VPOt35dJcAAAABYktHRAH/Ai3eAAAAB3RJTUUH5wMDFSE5W/eo1AAAAQtJREFUeNrt1NENgjAUQFFXYAVWYAVXcAVXYH0hoQlpSqGY2Dae82WE9971x8cDAAAAAAAAAAAAAAAAAADgR4aNAAEC/jNgPTwuBAgQ8J8B69FpI0CAgL4DhozczLgjQICAPgPCkSkjtXg/I0CAgD4Dzg4PJ8YEAQIE9BEQLyg5cEWYFyBAQHsBVxcPN8U7BAgQ0FbAlcNhcLohjkn+egECBFQPKPE8cXpQgAABzQXkwsIfUElwblaAAAF9BeyP3Z396rgAAQJ+EvCqTIAAAfUD3pUJECCgvYB5kfp89N28yR3J7RQgQED9gPjhfmG8/Oh56r1UYOpdAQIEtBFwtLBUyY7wrgABAqoHfABW2cbX3ElRgQAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyMy0wMy0wM1QyMTozMzo1NiswMDowMNpnAp0AAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjMtMDMtMDNUMjE6MzM6NTYrMDA6MDCrOrohAAAAKHRFWHRkYXRlOnRpbWVzdGFtcAAyMDIzLTAzLTAzVDIxOjMzOjU3KzAwOjAwWliQSgAAAABJRU5ErkJggg==";
189 use std::thread::JoinHandle;
190
191 use super::*;
192
193 #[test]
194 fn test_classic_l() -> Result<(), Error> {
195 let state = SvgState::new();
196 let xp = 49;
197 let data = mee6::LevelInfo::new(xp);
198 let mut customizations = Card::Classic.default_customizations();
199 customizations.toy = Some(Toy::Bee);
200 #[allow(
201 clippy::cast_precision_loss,
202 clippy::cast_sign_loss,
203 clippy::cast_possible_truncation
204 )]
205 let context = Context {
206 level: data.level(),
207 rank: 1,
208 name: "Testy McTestington".to_string(),
209 discriminator: None,
210 percentage: (data.percentage() * 100.0).round() as u64,
211 current: xp,
212 needed: mee6::xp_needed_for_level(data.level() + 1),
213 customizations,
214 avatar: VALK_PFP.to_string(),
215 };
216 let output = state.sync_render(&context)?;
217 std::fs::write("renderer_test_classic_l.png", output).unwrap();
218 Ok(())
219 }
220 #[test]
221 fn test_classic_r() -> Result<(), Error> {
222 let state = SvgState::new();
223 let xp = 51;
224 let data = mee6::LevelInfo::new(xp);
225 let mut customizations = Card::Classic.default_customizations();
226 customizations.toy = Some(Toy::Bee);
227 #[allow(
228 clippy::cast_precision_loss,
229 clippy::cast_sign_loss,
230 clippy::cast_possible_truncation
231 )]
232 let context = Context {
233 level: data.level(),
234 rank: 1,
235 name: "Testy McTestington".to_string(),
236 discriminator: None,
237 percentage: (data.percentage() * 100.0).round() as u64,
238 current: xp,
239 needed: mee6::xp_needed_for_level(data.level() + 1),
240 customizations,
241 avatar: VALK_PFP.to_string(),
242 };
243 let output = state.sync_render(&context)?;
244 std::fs::write("renderer_test_classic_r.png", output).unwrap();
245 Ok(())
246 }
247 #[test]
248 fn test_vertical() -> Result<(), Error> {
249 let state = SvgState::new();
250 let xp = 99;
251 let data = mee6::LevelInfo::new(xp);
252 let mut customizations = Card::Vertical.default_customizations();
253 customizations.font = Font::MontserratAlt1;
254 #[allow(
255 clippy::cast_precision_loss,
256 clippy::cast_sign_loss,
257 clippy::cast_possible_truncation
258 )]
259 let context = Context {
260 level: data.level(),
261 rank: 100_000,
262 name: "Testy McTestington".to_string(),
263 discriminator: None,
264 percentage: (data.percentage() * 100.0).round() as u64,
265 current: xp,
266 needed: mee6::xp_needed_for_level(data.level() + 1),
267 customizations,
268 avatar: VALK_PFP.to_string(),
269 };
270 let svg = state.render_svg(context.clone())?;
271 let png = state.sync_render(&context)?;
272 std::fs::write("renderer_test_vertical.svg", svg).unwrap();
273 std::fs::write("renderer_test_vertical.png", png).unwrap();
274 Ok(())
275 }
276 #[test]
277 #[ignore]
278 fn test_vertical_procedural() {
279 let mut handles: Vec<JoinHandle<()>> = Vec::with_capacity(100);
280 std::fs::create_dir_all("./test-procedural/").unwrap();
281 for xp in (1..=100).step_by(2) {
282 let spawn = std::thread::spawn(move || {
283 let state = SvgState::new();
284 let data = mee6::LevelInfo::new(xp);
285 #[allow(
286 clippy::cast_precision_loss,
287 clippy::cast_sign_loss,
288 clippy::cast_possible_truncation
289 )]
290 let context = Context {
291 level: data.level(),
292 rank: 1_000_000,
293 name: "Testy McTestington".to_string(),
294 discriminator: None,
295 percentage: (data.percentage() * 100.0).round() as u64,
296 current: xp,
297 needed: mee6::xp_needed_for_level(data.level() + 1),
298 customizations: Card::Vertical.default_customizations(),
299 avatar: VALK_PFP.to_string(),
300 };
301 let output = state.sync_render(&context).unwrap();
302 std::fs::write(
303 format!("./test-procedural/renderer_test_vertical_{xp:0>3}xp.png"),
304 output,
305 )
306 .unwrap();
307 });
308 handles.push(spawn);
309 }
310 for handle in handles {
311 handle.join().unwrap();
312 }
313 }
314}