xpd_rank_card/
lib.rs

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/// Context is the main argument of [`SvgState::render`], and takes parameters for what to put on
17/// the card.
18#[derive(serde::Serialize, Debug, Clone, PartialEq, Eq)]
19pub struct Context {
20    /// Level of the user for display
21    pub level: u64,
22    /// Rank of the user for display
23    pub rank: i64,
24    /// Username
25    pub name: String,
26    /// Optional, 4-character discriminator
27    pub discriminator: Option<String>,
28    /// Percentage of the way to the next level, out of 100
29    pub percentage: u64,
30    /// Current XP count
31    pub current: u64,
32    /// Total XP needed to complete this level
33    pub needed: u64,
34    /// Customization data
35    pub customizations: customizations::Customizations,
36    /// Base64-encoded PNG string.
37    pub avatar: String,
38}
39
40/// This struct should be constructed with [`SvgState::new`] to begin rendering rank cards
41#[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    /// Create a new [`SvgState`]
50    #[must_use]
51    pub fn new() -> Self {
52        Self::default()
53    }
54
55    /// this function renders an SVG on the internal thread pool, and returns PNG-encoded image
56    /// data on completion.
57    /// # Errors
58    /// Errors on [`resvg`](https://docs.rs/resvg) library failure. This will almost always be a library bug.
59    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    /// This function is very fast. It does not need to be async.
69    /// # Errors
70    /// Errors if tera has a problem
71    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    /// Render the PNG for a card.
78    /// # Errors
79    /// Errors if tera has a problem, or resvg does.
80    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}