1#.
5
6This crate provides flexible APIs for creating customizable status badges for CI, version, downloads, and more, supporting multiple styles (flat, plastic, social, for-the-badge, etc.).
7
8## Features
9
10- Generate SVG badge strings with custom label, message, color, logo, and links.
11- Multiple badge styles: flat, flat-square, plastic, social, for-the-badge.
12- Accurate text width calculation using embedded font width tables.
13- Builder pattern and parameter struct APIs.
14- Color normalization and aliasing (e.g., "critical" → red).
15- No runtime file I/O required for badge generation.
16
17### Example
18
19```rust
20use shields::{BadgeStyle, BadgeParams, render_badge_svg};
21
22let params = BadgeParams {
23 style: BadgeStyle::Flat,
24 label: Some("build"),
25 message: Some("passing"),
26 label_color: Some("green"),
27 message_color: Some("brightgreen"),
28 link: Some("https://ci.example.com"),
29 extra_link: None,
30 logo: None,
31 logo_color: None,
32};
33let svg = render_badge_svg(¶ms);
34assert!(svg.contains("passing"));
35```
36
37Or use the builder API:
38
39```rust
40use shields::{BadgeStyle};
41use shields::builder::Badge;
42
43let svg = Badge::style(BadgeStyle::Plastic)
44 .label("version")
45 .message("1.0.0")
46 .logo("github")
47 .build();
48assert!(svg.contains("version"));
49```
50
51See [`BadgeParams`](crate::BadgeParams), [`BadgeStyle`](crate::BadgeStyle), and [`BadgeBuilder`](crate::builder::BadgeBuilder) for details.
52
53"#]
54use askama::{Template, filters::capitalize};
55use std::str::FromStr;
56pub mod builder;
57pub mod measurer;
58use base64::Engine;
59use color_util::to_svg_color;
60use csscolorparser::Color;
61use serde::Deserialize;
62
63#[derive(Template)]
65#[template(path = "flat_badge_template.min.svg", escape = "none")]
66struct FlatBadgeSvgTemplateContext<'a> {
67 total_width: i32,
68 badge_height: i32,
69 accessible_text: &'a str,
70 left_width: i32,
71 right_width: i32,
72 label_color: &'a str,
73 message_color: &'a str,
74 font_family: &'a str,
75 font_size_scaled: i32,
76
77 label: &'a str,
78 label_x: f32,
79 label_width_scaled: i32,
80 label_text_color: &'a str,
81 label_shadow_color: &'a str,
82
83 message: &'a str,
84 message_x: f32,
85 message_shadow_color: &'a str,
86 message_text_color: &'a str,
87 message_width_scaled: i32,
88
89 link: &'a str,
90 extra_link: &'a str,
91
92 logo: &'a str,
93 rect_offset: i32,
94
95 message_link_x: i32,
96}
97#[derive(Template)]
99#[template(path = "flat_square_badge_template.min.svg", escape = "none")]
100struct FlatSquareBadgeSvgTemplateContext<'a> {
101 total_width: i32,
102 badge_height: i32,
103 accessible_text: &'a str,
104 left_width: i32,
105 right_width: i32,
106 label_color: &'a str,
107 message_color: &'a str,
108 font_family: &'a str,
109 font_size_scaled: i32,
110
111 label: &'a str,
112 label_x: f32,
113 label_width_scaled: i32,
114 label_text_color: &'a str,
115
116 message: &'a str,
117 message_x: f32,
118 message_text_color: &'a str,
119 message_width_scaled: i32,
120
121 link: &'a str,
122 extra_link: &'a str,
123 logo: &'a str,
124 rect_offset: i32,
125
126 message_link_x: i32,
127}
128#[derive(Template)]
130#[template(path = "plastic_badge_template.min.svg", escape = "none")]
131struct PlasticBadgeSvgTemplateContext<'a> {
132 total_width: i32,
133 accessible_text: &'a str,
134 left_width: i32,
135 right_width: i32,
136 label: &'a str,
138 label_x: f32,
139 label_text_length: i32,
140 label_text_color: &'a str,
141 label_shadow_color: &'a str,
142 message: &'a str,
143 message_x: f32,
144 message_text_length: i32,
145 message_text_color: &'a str,
146 message_shadow_color: &'a str,
147 label_color: &'a str,
148 message_color: &'a str,
149
150 link: &'a str,
151 extra_link: &'a str,
152
153 logo: &'a str,
154 rect_offset: i32,
155
156 message_link_x: i32,
157}
158
159#[derive(Template)]
161#[template(path = "social_badge_template.min.svg", escape = "none")]
162struct SocialBadgeSvgTemplateContext<'a> {
163 total_width: i32,
164 total_height: i32,
165 internal_height: u32,
166 accessible_text: &'a str,
167 label_rect_width: i32,
168 message_bubble_main_x: f32,
169 message_rect_width: u32,
170 message_bubble_notch_x: i32,
171 label_text_x: f32,
172 label_text_length: u32,
173 label: &'a str,
174 message_text_x: f32,
175 message_text_length: u32,
176 message: &'a str,
177
178 link: &'a str,
179 extra_link: &'a str,
180
181 logo: &'a str,
182}
183
184#[derive(Template)]
186#[template(path = "for_the_badge_template.min.svg", escape = "none")]
187struct ForTheBadgeSvgTemplateContext<'a> {
188 total_width: i32,
190
191 accessible_text: &'a str,
193
194 left_width: i32,
196 right_width: i32,
197
198 label_color: &'a str,
200 message_color: &'a str,
201
202 font_family: &'a str,
204 font_size: i32,
205
206 label: &'a str,
208 label_x: f32,
209 label_width_scaled: i32,
210 label_text_color: &'a str,
211
212 message: &'a str,
214 message_x: f32,
215 message_text_color: &'a str,
216 message_width_scaled: i32,
217
218 link: &'a str,
220 extra_link: &'a str,
221
222 logo: &'a str,
224 logo_x: i32,
225}
226
227mod color_util {
231 use csscolorparser::Color;
232 use lru::LruCache;
233 use once_cell::sync::Lazy;
234 use std::collections::HashMap;
235 use std::num::NonZeroUsize;
236 use std::str::FromStr;
237 use std::sync::Mutex;
238
239 pub static NAMED_COLORS: Lazy<HashMap<&'static str, &'static str>> = Lazy::new(|| {
241 HashMap::from([
242 ("brightgreen", "#4c1"),
243 ("green", "#97ca00"),
244 ("yellow", "#dfb317"),
245 ("yellowgreen", "#a4a61d"),
246 ("orange", "#fe7d37"),
247 ("red", "#e05d44"),
248 ("blue", "#007ec6"),
249 ("grey", "#555"),
250 ("lightgrey", "#9f9f9f"),
251 ])
252 });
253
254 pub static ALIASES: Lazy<HashMap<&'static str, &'static str>> = Lazy::new(|| {
256 HashMap::from([
257 ("gray", "grey"),
258 ("lightgray", "lightgrey"),
259 ("critical", "red"),
260 ("important", "orange"),
261 ("success", "brightgreen"),
262 ("informational", "blue"),
263 ("inactive", "lightgrey"),
264 ])
265 });
266
267 pub fn is_valid_hex(s: &str) -> bool {
269 let s = s.trim_start_matches('#');
270 let len = s.len();
271 (len == 3 || len == 6) && s.chars().all(|c| c.is_ascii_hexdigit())
272 }
273
274 pub fn is_css_color(s: &str) -> bool {
276 Color::from_str(s).is_ok()
277 }
278
279 pub fn normalize_color(color: &str) -> Option<String> {
281 static CACHE: Lazy<Mutex<LruCache<String, Option<String>>>> =
282 Lazy::new(|| Mutex::new(LruCache::new(NonZeroUsize::new(512).unwrap())));
283 let color = color.trim();
284 if color.is_empty() {
285 return None;
286 }
287 let key = color.to_ascii_lowercase();
288 if let Some(cached) = {
290 let mut cache = CACHE.lock().unwrap();
291 cache.get(&key).cloned()
292 } {
293 return cached;
294 }
295 let lower = color.to_ascii_lowercase();
297 let result = if NAMED_COLORS.contains_key(lower.as_str()) {
298 Some(lower.to_string())
299 } else if let Some(&alias) = ALIASES.get(lower.as_str()) {
300 Some(alias.to_string())
301 } else if is_valid_hex(lower.as_str()) {
302 let hex = lower.trim_start_matches('#');
303 Some(format!("#{}", hex))
304 } else if is_css_color(lower.as_str()) {
305 Some(lower.to_string())
306 } else {
307 None
308 };
309 let mut cache = CACHE.lock().unwrap();
310 cache.put(key, result.clone());
311 result
312 }
313
314 pub fn to_svg_color(color: &str) -> Option<String> {
316 static CACHE: Lazy<Mutex<LruCache<String, Option<String>>>> =
317 Lazy::new(|| Mutex::new(LruCache::new(NonZeroUsize::new(256).unwrap())));
318 let key = color.to_ascii_lowercase();
319 if let Some(cached) = {
320 let mut cache = CACHE.lock().unwrap();
321 cache.get(&key).cloned()
322 } {
323 return cached;
324 }
325 let normalized = normalize_color(color)?;
326 let result = if let Some(&hex) = NAMED_COLORS.get(normalized.as_str()) {
327 Some(hex.to_string())
328 } else if let Some(&alias) = ALIASES.get(normalized.as_str()) {
329 NAMED_COLORS.get(alias).map(|&h| h.to_string())
330 } else {
331 Some(normalized)
332 };
333 let mut cache = CACHE.lock().unwrap();
334 cache.put(key, result.clone());
335 result
336 }
337}
338pub trait FontMetrics {
340 fn get_text_width_px(&self, text: &str, font_family: &str) -> f32;
342}
343
344#[derive(Eq, PartialEq, Hash, Clone, Debug)]
346pub enum Font {
347 VerdanaNormal11,
349 HelveticaBold11,
351 VerdanaNormal10,
353 VerdanaBold10,
355}
356
357pub fn get_text_width(text: &str, font: Font) -> f64 {
363 use crate::measurer::CharWidthMeasurer;
364 use once_cell::sync::Lazy;
365
366 const VERDANA_11_N_JSON_DATA: &str = include_str!("../assets/fonts/verdana-11px-normal.json");
368 const HELVETICA_11_B_JSON_DATA: &str = include_str!("../assets/fonts/helvetica-11px-bold.json");
369 const VERDANA_10_N_JSON_DATA: &str = include_str!("../assets/fonts/verdana-10px-normal.json");
370 const VERDANA_10_B_JSON_DATA: &str = include_str!("../assets/fonts/verdana-10px-bold.json");
371 static VERDANA_11_N_WIDTH_TABLE: Lazy<CharWidthMeasurer> = Lazy::new(|| {
372 CharWidthMeasurer::load_from_str(VERDANA_11_N_JSON_DATA)
374 .expect("Unable to parse Verdana 11px width table")
375 });
376
377 static HELVETICA_11_B_WIDTH_TABLE: Lazy<CharWidthMeasurer> = Lazy::new(|| {
378 CharWidthMeasurer::load_from_str(HELVETICA_11_B_JSON_DATA)
380 .expect("Unable to parse Helvetica Bold width table")
381 });
382 static VERDANA_10_N_WIDTH_TABLE: Lazy<CharWidthMeasurer> = Lazy::new(|| {
383 CharWidthMeasurer::load_from_str(VERDANA_10_N_JSON_DATA)
384 .expect("Unable to parse Verdana 10px width table")
385 });
386
387 static VERDANA_10_B_WIDTH_TABLE: Lazy<CharWidthMeasurer> = Lazy::new(|| {
388 CharWidthMeasurer::load_from_str(VERDANA_10_B_JSON_DATA)
389 .expect("Unable to parse Verdana 10px Bold width table")
390 });
391
392 match font {
393 Font::VerdanaNormal11 => VERDANA_11_N_WIDTH_TABLE.width_of(text, true),
394 Font::HelveticaBold11 => HELVETICA_11_B_WIDTH_TABLE.width_of(text, true),
395 Font::VerdanaNormal10 => VERDANA_10_N_WIDTH_TABLE.width_of(text, true),
396 Font::VerdanaBold10 => VERDANA_10_B_WIDTH_TABLE.width_of(text, true),
397 }
398}
399macro_rules! round_up_to_odd_float {
400 ($func:ident, $float:ty) => {
401 fn $func(n: $float) -> u32 {
402 let n_rounded = n.floor() as u32;
403 if n_rounded % 2 == 0 {
404 n_rounded + 1
405 } else {
406 n_rounded
407 }
408 }
409 };
410}
411
412round_up_to_odd_float!(round_up_to_odd_f64, f64);
413const BADGE_HEIGHT: u32 = 20;
414const HORIZONTAL_PADDING: u32 = 5;
415const FONT_FAMILY: &str = "Verdana,Geneva,DejaVu Sans,sans-serif";
416const FONT_SIZE_SCALED: u32 = 110;
417const FONT_SCALE_UP_FACTOR: u32 = 10;
418pub fn colors_for_background(hex: &str) -> (&'static str, &'static str) {
426 let hex = hex.trim_start_matches('#');
428 let (r, g, b) = match hex.len() {
430 3 => (
431 {
432 let c = hex.as_bytes()[0];
433 let v = match c {
434 b'0'..=b'9' => c - b'0',
435 b'a'..=b'f' => c - b'a' + 10,
436 b'A'..=b'F' => c - b'A' + 10,
437 _ => 0,
438 };
439 (v << 4) | v
440 },
441 {
442 let c = hex.as_bytes()[1];
443 let v = match c {
444 b'0'..=b'9' => c - b'0',
445 b'a'..=b'f' => c - b'a' + 10,
446 b'A'..=b'F' => c - b'A' + 10,
447 _ => 0,
448 };
449 (v << 4) | v
450 },
451 {
452 let c = hex.as_bytes()[2];
453 let v = match c {
454 b'0'..=b'9' => c - b'0',
455 b'a'..=b'f' => c - b'a' + 10,
456 b'A'..=b'F' => c - b'A' + 10,
457 _ => 0,
458 };
459 (v << 4) | v
460 },
461 ),
462 6 => (
463 u8::from_str_radix(&hex[0..2], 16).unwrap_or(0),
464 u8::from_str_radix(&hex[2..4], 16).unwrap_or(0),
465 u8::from_str_radix(&hex[4..6], 16).unwrap_or(0),
466 ),
467 _ => (0, 0, 0), };
469 let brightness = (0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32) / 255.0;
471 if brightness <= 0.69 {
472 ("#fff", "#010101")
473 } else {
474 ("#333", "#ccc")
475 }
476}
477pub(crate) fn preferred_width_of(text: &str, font: Font) -> u32 {
478 use lru::LruCache;
479 use once_cell::sync::Lazy;
480 use std::num::NonZeroUsize;
481 use std::sync::Mutex;
482
483 static WIDTH_CACHE: Lazy<Mutex<LruCache<(String, Font), u32>>> =
485 Lazy::new(|| Mutex::new(LruCache::new(NonZeroUsize::new(1024).unwrap())));
486
487 let cache_key = (text.to_string(), font.clone());
488
489 {
490 let mut cache = WIDTH_CACHE.lock().unwrap();
491 if let Some(&cached) = cache.get(&cache_key) {
492 return cached;
493 }
494 }
495
496 let width = get_text_width(text, font);
497 let rounded = round_up_to_odd_f64(width);
498
499 if text.len() <= 1024 {
500 let mut cache = WIDTH_CACHE.lock().unwrap();
501 cache.put(cache_key, rounded);
502 }
503
504 rounded
505}
506
507#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
508#[serde(rename_all = "kebab-case")]
509pub enum BadgeStyle {
523 Flat,
525 FlatSquare,
527 Plastic,
529 Social,
531 ForTheBadge,
533}
534
535impl Default for BadgeStyle {
536 fn default() -> Self {
538 BadgeStyle::Flat
539 }
540}
541
542pub fn default_message_color() -> &'static str {
544 "#007ec6"
545}
546
547pub fn default_label_color() -> &'static str {
549 "#555"
550}
551
552#[derive(Deserialize, Debug)]
553pub struct BadgeParams<'a> {
586 #[serde(default)]
587 pub style: BadgeStyle,
589 pub label: Option<&'a str>,
591 pub message: Option<&'a str>,
593 pub label_color: Option<&'a str>,
595 pub message_color: Option<&'a str>,
597 pub link: Option<&'a str>,
599 pub extra_link: Option<&'a str>,
601 pub logo: Option<&'a str>,
603 pub logo_color: Option<&'a str>,
605}
606
607pub fn render_badge_svg(params: &BadgeParams) -> String {
633 let BadgeParams {
634 style,
635 label,
636 message,
637 label_color,
638 message_color,
639 link,
640 extra_link,
641 logo,
642 logo_color,
643 } = params;
644 let label = *label;
645 let default_logo_color = if *style == BadgeStyle::Social {
646 "#000000"
647 } else {
648 "whitesmoke"
649 };
650
651 let logo_color = logo_color.unwrap_or(default_logo_color);
652 let logo_color = to_svg_color(logo_color).unwrap_or(default_logo_color.to_string());
653 let icon_svg = match logo {
654 Some(logo) => {
655 let logo = logo.trim();
656 if logo.is_empty() {
657 ""
658 } else if logo.starts_with("<svg") {
659 logo
660 } else {
661 let icon = logo;
664 let svg = simpleicons::Icon::get_svg(icon);
665 svg.unwrap_or_default()
666 }
667 }
668 None => "",
669 };
670 let logo = if icon_svg.starts_with("<svg") {
672 let svg_tag_end = icon_svg.find('>').unwrap_or(0);
674 let svg_tag = &icon_svg[..svg_tag_end];
675 let has_fill_in_svg_tag = svg_tag.contains("fill=");
676 let logo_svg = if !has_fill_in_svg_tag && !logo_color.is_empty() {
677 icon_svg.replace("<svg", format!("<svg fill=\"{}\"", logo_color).as_str())
678 } else {
679 icon_svg.to_string()
680 };
681 let base64_logo = base64::engine::general_purpose::STANDARD.encode(logo_svg);
682 format!("data:image/svg+xml;base64,{}", base64_logo)
683 } else {
684 icon_svg.to_string()
685 };
686 let has_logo = !logo.is_empty();
687 let logo_width = 14;
688 let mut logo_padding = 3;
689 if label.is_some() && label.unwrap().is_empty() {
690 logo_padding = 0;
691 }
692
693 let total_logo_width = if has_logo {
694 logo_width + logo_padding
695 } else {
696 0
697 };
698
699 let has_label_color = !label_color.unwrap_or("").is_empty();
700 let message_color = message_color.unwrap_or(default_message_color());
701 let message_color = to_svg_color(message_color).unwrap_or("#007ec6".to_string());
702
703 let label_color = match (
704 label.unwrap_or("").is_empty(),
705 label_color.unwrap_or("").is_empty(),
706 ) {
707 (true, true) if has_logo => "#555",
708 (true, true) => message_color.as_str(),
709 (_, _) => label_color.unwrap_or(default_label_color()),
710 };
711
712 let binding = to_svg_color(label_color).unwrap_or("#555".to_string());
713 let label_color = binding.as_str();
714
715 let message_color = message_color.as_str();
716 let message = message.unwrap_or("");
717 let link = link.unwrap_or("");
718 let extra_link_not_empty_str = extra_link.is_none() || !extra_link.unwrap().is_empty();
719 let extra_link = extra_link.unwrap_or("");
720 let logo = logo.as_str();
721 match style {
722 BadgeStyle::Flat => {
723 let accessible_text = create_accessible_text(label, message);
724 let has_label_content = label.is_some() && !label.unwrap().is_empty();
725 let has_label = has_label_content || has_label_color;
726 let label_margin = total_logo_width + 1;
727
728 let label_width = if has_label && label.is_some() {
729 preferred_width_of(label.unwrap_or_default(), Font::VerdanaNormal11)
730 } else {
731 0
732 };
733
734 let mut left_width = if has_label {
735 (label_width + 2 * HORIZONTAL_PADDING + total_logo_width) as i32
736 } else {
737 0
738 };
739
740 if has_label && label.is_some() {
741 let label = label.unwrap();
742 if label.is_empty() {
743 left_width -= 1;
744 }
745 }
746 let message_width = preferred_width_of(message, Font::VerdanaNormal11);
747
748 let offset = if label.is_none() && has_logo {
749 -3i32
750 } else {
751 0
752 };
753
754 let left_width = left_width + offset as i32;
755 let mut message_margin: i32 =
756 left_width as i32 - if message.is_empty() { 0 } else { 1 };
757 if !has_label {
758 if has_logo {
759 message_margin += (total_logo_width + HORIZONTAL_PADDING) as i32
760 } else {
761 message_margin += 1
762 }
763 }
764
765 let mut right_width = (message_width + 2 * HORIZONTAL_PADDING) as i32;
766 if has_logo && !has_label {
767 right_width += total_logo_width as i32
768 + if !message.is_empty() {
769 (HORIZONTAL_PADDING - 1) as i32
770 } else {
771 0i32
772 };
773 }
774
775 let label_x = 10.0
776 * (label_margin as f32 + (0.5 * label_width as f32) + HORIZONTAL_PADDING as f32)
777 + offset as f32;
778 let label_width_scaled = label_width * 10;
779 let total_width = left_width + right_width as i32;
780
781 let right_width = right_width + if !has_label_color { offset } else { 0 };
782 let hex_label_color = Color::from_str(label_color)
783 .unwrap_or(Color::from_str("#555").unwrap())
784 .to_css_hex();
785 let hex_label_color = hex_label_color.as_str();
786 let hex_message_color = Color::from_str(message_color)
787 .unwrap_or(Color::from_str("#007ec6").unwrap())
788 .to_css_hex();
789 let hex_message_color = hex_message_color.as_str();
790 let (label_text_color, label_shadow_color) = colors_for_background(hex_label_color);
791 let (message_text_color, message_shadow_color) =
792 colors_for_background(hex_message_color);
793 let rect_offset = if has_logo { 19 } else { 0 };
794
795 let message_link_x = if has_logo && !has_label && extra_link_not_empty_str {
796 total_logo_width as i32 + HORIZONTAL_PADDING as i32
797 } else {
798 left_width
799 };
800
801 let has_extra_link = !extra_link.is_empty();
802 let message_x = 10.0
803 * (message_margin as f32
804 + (0.5 * message_width as f32)
805 + HORIZONTAL_PADDING as f32);
806 let message_link_x = message_link_x
807 + if !has_label && has_extra_link {
808 offset
809 } else {
810 0
811 } as i32;
812 let message_width_scaled = message_width * 10;
813 let left_width = if left_width < 0 { 0 } else { left_width };
814 FlatBadgeSvgTemplateContext {
815 font_family: FONT_FAMILY,
816
817 accessible_text: accessible_text.as_str(),
818 badge_height: BADGE_HEIGHT as i32,
819
820 left_width: left_width as i32,
821 right_width: right_width as i32,
822 total_width: total_width as i32,
823
824 label_color,
825 message_color,
826
827 font_size_scaled: FONT_SIZE_SCALED as i32,
828
829 label: label.unwrap_or(""),
830 label_x,
831 label_width_scaled: label_width_scaled as i32,
832 label_text_color,
833 label_shadow_color,
834
835 message_x,
836 message_shadow_color,
837 message_text_color,
838 message_width_scaled: message_width_scaled as i32,
839 message,
840
841 link,
842 extra_link,
843 logo,
844
845 rect_offset,
846 message_link_x,
847 }
848 .render()
849 .unwrap_or_else(|e| format!("<!-- Askama render error: {} -->", e))
850 }
851 BadgeStyle::FlatSquare => {
852 let accessible_text = create_accessible_text(label, message);
853 let has_label_content = label.is_some() && !label.unwrap().is_empty();
854 let has_label = has_label_content || has_label_color;
855 let label_margin = total_logo_width + 1;
856
857 let label_width = if has_label && label.is_some() {
858 preferred_width_of(label.unwrap_or_default(), Font::VerdanaNormal11)
859 } else {
860 0
861 };
862
863 let mut left_width = if has_label {
864 (label_width + 2 * HORIZONTAL_PADDING + total_logo_width) as i32
865 } else {
866 0
867 };
868
869 if has_label && label.is_some() {
870 let label = label.unwrap();
871 if label.is_empty() {
872 left_width -= 1;
873 }
874 }
875 let message_width = preferred_width_of(message, Font::VerdanaNormal11);
876
877 let offset = if label.is_none() && has_logo {
878 -3i32
879 } else {
880 0
881 };
882
883 let left_width = left_width + offset as i32;
884 let mut message_margin: i32 =
885 left_width as i32 - if message.is_empty() { 0 } else { 1 };
886 if !has_label {
887 if has_logo {
888 message_margin += (total_logo_width + HORIZONTAL_PADDING) as i32
889 } else {
890 message_margin += 1
891 }
892 }
893
894 let mut right_width = (message_width + 2 * HORIZONTAL_PADDING) as i32;
895 if has_logo && !has_label {
896 right_width += total_logo_width as i32
897 + if !message.is_empty() {
898 (HORIZONTAL_PADDING - 1) as i32
899 } else {
900 0i32
901 };
902 }
903
904 let label_x = 10.0
905 * (label_margin as f32 + (0.5 * label_width as f32) + HORIZONTAL_PADDING as f32)
906 + offset as f32;
907 let label_width_scaled = label_width * 10;
908 let total_width = left_width + right_width as i32;
909
910 let right_width = right_width + if !has_label_color { offset } else { 0 };
911 let hex_label_color = Color::from_str(label_color)
912 .unwrap_or(Color::from_str("#555").unwrap())
913 .to_css_hex();
914 let hex_label_color = hex_label_color.as_str();
915 let hex_message_color = Color::from_str(message_color)
916 .unwrap_or(Color::from_str("#007ec6").unwrap())
917 .to_css_hex();
918 let hex_message_color = hex_message_color.as_str();
919 let (label_text_color, _) = colors_for_background(hex_label_color);
920 let (message_text_color, _) = colors_for_background(hex_message_color);
921 let rect_offset = if has_logo { 19 } else { 0 };
922
923 let message_link_x = if has_logo && !has_label && extra_link_not_empty_str {
924 total_logo_width as i32 + HORIZONTAL_PADDING as i32
925 } else {
926 left_width
927 };
928
929 let has_extra_link = !extra_link.is_empty();
930 let message_x = 10.0
931 * (message_margin as f32
932 + (0.5 * message_width as f32)
933 + HORIZONTAL_PADDING as f32);
934 let message_link_x = message_link_x
935 + if !has_label && has_extra_link {
936 offset
937 } else {
938 0
939 } as i32;
940 let message_width_scaled = message_width * 10;
941 let left_width = if left_width < 0 { 0 } else { left_width };
942 FlatSquareBadgeSvgTemplateContext {
943 font_family: FONT_FAMILY,
944 accessible_text: accessible_text.as_str(),
945 badge_height: BADGE_HEIGHT as i32,
946 left_width,
947 right_width,
948 total_width,
949 label_color,
950 message_color,
951 font_size_scaled: FONT_SIZE_SCALED as i32,
952 label: label.unwrap_or(""),
953 label_x,
954 label_width_scaled: label_width_scaled as i32,
955 label_text_color,
956 message_x,
957 message_text_color,
958 message_width_scaled: message_width_scaled as i32,
959 message,
960 link,
961 extra_link,
962 logo,
963 rect_offset,
964 message_link_x,
965 }
966 .render()
967 .unwrap_or_else(|e| format!("<!-- Askama render error: {} -->", e))
968 }
969 BadgeStyle::Plastic => {
970 let accessible_text = create_accessible_text(label, message);
971 let has_label_content = label.is_some() && !label.unwrap().is_empty();
972 let has_label = has_label_content || has_label_color;
973 let label_margin = total_logo_width + 1;
974
975 let label_width = if has_label && label.is_some() {
976 preferred_width_of(label.unwrap_or_default(), Font::VerdanaNormal11)
977 } else {
978 0
979 };
980
981 let mut left_width = if has_label {
982 (label_width + 2 * HORIZONTAL_PADDING + total_logo_width) as i32
983 } else {
984 0
985 };
986
987 if has_label && label.is_some() {
988 let label = label.unwrap();
989 if label.is_empty() {
990 left_width -= 1;
991 }
992 }
993 let message_width = preferred_width_of(message, Font::VerdanaNormal11);
994
995 let offset = if label.is_none() && has_logo {
996 -3i32
997 } else {
998 0
999 };
1000
1001 let left_width = left_width + offset as i32;
1002 let mut message_margin: i32 =
1003 left_width as i32 - if message.is_empty() { 0 } else { 1 };
1004 if !has_label {
1005 if has_logo {
1006 message_margin += (total_logo_width + HORIZONTAL_PADDING) as i32;
1007 } else {
1008 message_margin += 1
1009 }
1010 }
1011
1012 let mut right_width = (message_width + 2 * HORIZONTAL_PADDING) as i32;
1013 if has_logo && !has_label {
1014 right_width += total_logo_width as i32
1015 + if !message.is_empty() {
1016 (HORIZONTAL_PADDING - 1) as i32
1017 } else {
1018 0i32
1019 };
1020 }
1021
1022 let label_x = 10.0
1023 * (label_margin as f32 + (0.5 * label_width as f32) + HORIZONTAL_PADDING as f32)
1024 + offset as f32;
1025 let label_width_scaled = label_width * 10;
1026 let total_width = left_width + right_width as i32;
1027
1028 let right_width = right_width + if !has_label_color { offset } else { 0 };
1029 let hex_label_color = Color::from_str(label_color)
1030 .unwrap_or(Color::from_str("#555").unwrap())
1031 .to_css_hex();
1032 let hex_label_color = hex_label_color.as_str();
1033 let hex_message_color = Color::from_str(message_color)
1034 .unwrap_or(Color::from_str("#007ec6").unwrap())
1035 .to_css_hex();
1036 let hex_message_color = hex_message_color.as_str();
1037 let (label_text_color, label_shadow_color) = colors_for_background(hex_label_color);
1038 let (message_text_color, message_shadow_color) =
1039 colors_for_background(hex_message_color);
1040 let rect_offset = if has_logo { 19 } else { 0 };
1041
1042 let message_link_x = if has_logo && !has_label && extra_link_not_empty_str {
1043 total_logo_width as i32 + HORIZONTAL_PADDING as i32
1044 } else {
1045 left_width
1046 };
1047
1048 let has_extra_link = !extra_link.is_empty();
1049 let message_x = 10.0
1050 * (message_margin as f32
1051 + (0.5 * message_width as f32)
1052 + HORIZONTAL_PADDING as f32);
1053 let message_link_x = message_link_x
1054 + if !has_label && has_extra_link {
1055 offset
1056 } else {
1057 0
1058 } as i32;
1059 let message_width_scaled = message_width * 10;
1060 let left_width = if left_width < 0 { 0 } else { left_width };
1061 PlasticBadgeSvgTemplateContext {
1062 total_width,
1063 left_width,
1064 right_width,
1065 accessible_text: accessible_text.as_str(),
1066 label: label.unwrap_or(""),
1067 label_x,
1068 label_text_length: label_width_scaled as i32,
1069 label_text_color,
1070 label_shadow_color,
1071 message,
1072 message_x,
1073 message_text_length: message_width_scaled as i32,
1074 message_text_color,
1075 message_shadow_color,
1076 label_color,
1077 message_color,
1078 link,
1079 extra_link,
1080 logo,
1081 rect_offset,
1082 message_link_x,
1083 }
1084 .render()
1085 .unwrap_or_else(|e| format!("<!-- Askama render error: {} -->", e))
1086 }
1087 BadgeStyle::Social => {
1088 let label_is_none = label.is_none();
1089
1090 let offset = if label_is_none && has_logo {
1091 -3i32
1092 } else {
1093 0i32
1094 };
1095
1096 let label = label.unwrap_or("");
1097 let label = capitalize(label).unwrap().to_string();
1098 let label_str = label.as_str();
1099 let accessible_text = create_accessible_text(Some(label_str), message);
1100 let internal_height = 19;
1101 let label_horizontal_padding = 5;
1102 let message_horizontal_padding = 4;
1103 let horizontal_gutter = 6;
1104
1105 let label_text_width = preferred_width_of(label_str, Font::HelveticaBold11);
1106
1107 let label_rect_width =
1108 (label_text_width + total_logo_width + 2 * label_horizontal_padding) as i32
1109 + offset;
1110
1111 let message_text_width = preferred_width_of(message, Font::HelveticaBold11);
1112
1113 let message_rect_width = message_text_width + 2 * message_horizontal_padding;
1114 let has_message = !message.is_empty();
1115
1116 let message_bubble_main_x = label_rect_width as f32 + horizontal_gutter as f32 + 0.5;
1117 let message_bubble_notch_x = label_rect_width + horizontal_gutter;
1118 let label_text_x = FONT_SCALE_UP_FACTOR as f32
1119 * (total_logo_width as f32
1120 + label_text_width as f32 / 2.0
1121 + label_horizontal_padding as f32
1122 + offset as f32);
1123 let message_text_x = FONT_SCALE_UP_FACTOR as f32
1124 * (label_rect_width as f32
1125 + horizontal_gutter as f32
1126 + message_rect_width as f32 / 2.0);
1127 let message_text_length = FONT_SCALE_UP_FACTOR * message_text_width;
1128 let label_text_length = FONT_SCALE_UP_FACTOR * label_text_width;
1129
1130 let left_width = label_rect_width + 1;
1131 let right_width = if has_message {
1132 horizontal_gutter + message_rect_width as i32
1133 } else {
1134 0
1135 };
1136
1137 let total_width = left_width + right_width as i32;
1138
1139 SocialBadgeSvgTemplateContext {
1140 total_width,
1141 total_height: BADGE_HEIGHT as i32,
1142 internal_height,
1143 accessible_text: accessible_text.as_str(),
1144 message_rect_width,
1145 message_bubble_main_x,
1146 message_bubble_notch_x,
1147 label_text_length,
1148 label: label_str,
1149 message,
1150 label_text_x,
1151 message_text_x,
1152 message_text_length,
1153 label_rect_width,
1154 link,
1155 extra_link,
1156 logo,
1157 }
1158 .render()
1159 .unwrap_or_else(|e| format!("<!-- Askama render error: {} -->", e))
1160 }
1161 BadgeStyle::ForTheBadge => {
1162 let label = label.unwrap_or("").to_uppercase();
1164 let accessible_text = create_accessible_text(Some(label.as_str()), message);
1165 let message = message.to_uppercase();
1166 let font_size = 10;
1167 let letter_spacing = 1.25;
1168 let logo_text_gutter = 6i32;
1169 let logo_margin = 9i32;
1170 let logo_width = logo_width as i32;
1171 let label_text_width = if !label.is_empty() {
1172 (get_text_width(&label, Font::VerdanaNormal10)
1173 + letter_spacing * label.len() as f64) as i32
1174 } else {
1175 0
1176 };
1177 let message_text_width = if !message.is_empty() {
1178 (get_text_width(&message, Font::VerdanaBold10)
1179 + letter_spacing * message.len() as f64) as i32
1180 } else {
1181 0
1182 };
1183 let has_label = !label.is_empty();
1184 let no_text = !has_label && message.is_empty();
1185 let need_label_rect = has_label || (!logo.is_empty() && !label_color.is_empty());
1186 let gutter = if no_text {
1187 logo_text_gutter - logo_margin
1188 } else {
1189 logo_text_gutter
1190 };
1191 let text_margin = 12;
1192
1193 let (logo_min_x, label_text_min_x) = if !logo.is_empty() {
1195 (logo_margin, logo_margin + logo_width + gutter)
1196 } else {
1197 (0, text_margin)
1198 };
1199
1200 let (label_rect_width, message_text_min_x, message_rect_width) = if need_label_rect {
1202 if has_label {
1203 (
1204 label_text_min_x + label_text_width + text_margin,
1205 label_text_min_x + label_text_width + text_margin + text_margin,
1206 2 * text_margin + message_text_width,
1207 )
1208 } else {
1209 (
1210 2 * logo_margin + logo_width,
1211 2 * logo_margin + logo_width + text_margin,
1212 2 * text_margin + message_text_width,
1213 )
1214 }
1215 } else if !logo.is_empty() {
1216 (
1217 0,
1218 text_margin + logo_width + gutter,
1219 2 * text_margin + logo_width + gutter + message_text_width,
1220 )
1221 } else {
1222 (0, text_margin, 2 * text_margin + message_text_width)
1223 };
1224 let left_width = label_rect_width;
1225 let right_width = message_rect_width;
1226 let total_width = left_width + right_width;
1227
1228 let hex_label_color = Color::from_str(label_color)
1229 .unwrap_or(Color::from_str("#555").unwrap())
1230 .to_css_hex();
1231 let hex_label_color = hex_label_color.as_str();
1232 let hex_message_color = Color::from_str(message_color)
1233 .unwrap_or(Color::from_str("#007ec6").unwrap())
1234 .to_css_hex();
1235 let hex_message_color = hex_message_color.as_str();
1236
1237 let message_mid_x = message_text_min_x as f32 + 0.5 * message_text_width as f32;
1238 let label_mid_x = label_text_min_x as f32 + 0.5 * label_text_width as f32;
1239
1240 let (label_text_color, _) = colors_for_background(hex_label_color);
1241 let (message_text_color, _) = colors_for_background(hex_message_color);
1242
1243 ForTheBadgeSvgTemplateContext {
1244 total_width,
1245 accessible_text: accessible_text.as_str(),
1246 left_width: label_rect_width,
1247 right_width: message_rect_width,
1248 label_color,
1249 message_color,
1250 font_family: FONT_FAMILY,
1251 font_size: font_size * FONT_SCALE_UP_FACTOR as i32,
1252 label: label.as_str(),
1253 label_x: label_mid_x * FONT_SCALE_UP_FACTOR as f32,
1254 label_width_scaled: label_text_width * FONT_SCALE_UP_FACTOR as i32,
1255 label_text_color,
1256 message: message.as_str(),
1257 message_x: message_mid_x * FONT_SCALE_UP_FACTOR as f32,
1258 message_text_color,
1259 message_width_scaled: message_text_width * FONT_SCALE_UP_FACTOR as i32,
1260 link,
1261 extra_link,
1262 logo,
1263 logo_x: logo_min_x,
1264 }
1265 .render()
1266 .unwrap_or_else(|e| format!("<!-- Askama render error: {} -->", e))
1267 }
1268 }
1269}
1270
1271fn create_accessible_text(label: Option<&str>, message: &str) -> String {
1272 let use_label = match label {
1273 Some(l) if !l.is_empty() => Some(l),
1274 _ => None,
1275 };
1276 let label_len = use_label.map_or(0, |l| l.len() + 2); let mut buf = String::with_capacity(label_len + message.len());
1278 if let Some(label) = use_label {
1279 buf.push_str(label);
1280 buf.push_str(": ");
1281 }
1282 buf.push_str(message);
1283 buf
1284}
1285
1286#[cfg(test)]
1287mod tests {
1288 use csscolorparser::Color;
1289 use pretty_assertions::assert_eq;
1290 use std::str::FromStr;
1291
1292 use super::*;
1293 #[test]
1294 fn test_svg() {
1295 let params = BadgeParams {
1297 style: BadgeStyle::FlatSquare,
1298 label: Some("build"),
1299 message: Some("passing"),
1300 label_color: Some("#333"),
1301 message_color: Some("#4c1"),
1302 link: None,
1303 extra_link: None,
1304 logo: None,
1305 logo_color: None,
1306 };
1307 let svg = render_badge_svg(¶ms);
1308 assert!(!svg.is_empty(), "SVG rendering failed");
1309 }
1310
1311 #[test]
1312 fn text_for_the_badge() {
1313 let params = BadgeParams {
1315 style: BadgeStyle::ForTheBadge,
1316 label: Some("building"),
1317 message: Some("pass"),
1318 label_color: Some("#555"),
1319 message_color: Some("#fff"),
1320 link: Some("https://google.com"),
1321 extra_link: Some("https://example.com"),
1322 logo: Some("rust"),
1323 logo_color: Some("blue"),
1324 };
1325 let svg = render_badge_svg(¶ms);
1326 println!("{}", svg);
1327 let expected = r##"<svg xmlns="http://www.w3.org/2000/svg" width="160" height="28"><g shape-rendering="crispEdges"><rect width="102" height="28" fill="#555"/><rect x="102" width="58" height="28" fill="#fff"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="100"><image x="9" y="7" width="14" height="14" href="data:image/svg+xml;base64,PHN2ZyBmaWxsPSIjMDA3ZWM2IiByb2xlPSJpbWciIHZpZXdCb3g9IjAgMCAyNCAyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48dGl0bGU+UnVzdDwvdGl0bGU+PHBhdGggZD0iTTIzLjgzNDYgMTEuNzAzM2wtMS4wMDczLS42MjM2YTEzLjcyNjggMTMuNzI2OCAwIDAwLS4wMjgzLS4yOTM2bC44NjU2LS44MDY5YS4zNDgzLjM0ODMgMCAwMC0uMTE1NC0uNTc4bC0xLjEwNjYtLjQxNGE4LjQ5NTggOC40OTU4IDAgMDAtLjA4Ny0uMjg1NmwuNjkwNC0uOTU4N2EuMzQ2Mi4zNDYyIDAgMDAtLjIyNTctLjU0NDZsLTEuMTY2My0uMTg5NGE5LjM1NzQgOS4zNTc0IDAgMDAtLjE0MDctLjI2MjJsLjQ5LTEuMDc2MWEuMzQzNy4zNDM3IDAgMDAtLjAyNzQtLjMzNjEuMzQ4Ni4zNDg2IDAgMDAtLjMwMDYtLjE1NGwtMS4xODQ1LjA0MTZhNi43NDQ0IDYuNzQ0NCAwIDAwLS4xODczLS4yMjY4bC4yNzIzLTEuMTUzYS4zNDcyLjM0NzIgMCAwMC0uNDE3LS40MTcybC0xLjE1MzIuMjcyNGExNC4wMTgzIDE0LjAxODMgMCAwMC0uMjI3OC0uMTg3M2wuMDQxNS0xLjE4NDVhLjM0NDIuMzQ0MiAwIDAwLS40OS0uMzI4bC0xLjA3Ni40OTFjLS4wODcyLS4wNDc2LS4xNzQyLS4wOTUyLS4yNjIzLS4xNDA3bC0uMTkwMy0xLjE2NzNBLjM0ODMuMzQ4MyAwIDAwMTYuMjU2Ljk1NWwtLjk1OTcuNjkwNWE4LjQ4NjcgOC40ODY3IDAgMDAtLjI4NTUtLjA4NmwtLjQxNC0xLjEwNjZhLjM0ODMuMzQ4MyAwIDAwLS41NzgxLS4xMTU0bC0uODA2OS44NjY2YTkuMjkzNiA5LjI5MzYgMCAwMC0uMjkzNi0uMDI4NEwxMi4yOTQ2LjE2ODNhLjM0NjIuMzQ2MiAwIDAwLS41ODkyIDBsLS42MjM2IDEuMDA3M2ExMy43MzgzIDEzLjczODMgMCAwMC0uMjkzNi4wMjg0TDkuOTgwMy4zMzc0YS4zNDYyLjM0NjIgMCAwMC0uNTc4LjExNTRsLS40MTQxIDEuMTA2NWMtLjA5NjIuMDI3NC0uMTkwMy4wNTY3LS4yODU1LjA4Nkw3Ljc0NC45NTVhLjM0ODMuMzQ4MyAwIDAwLS41NDQ3LjIyNThMNy4wMDkgMi4zNDhhOS4zNTc0IDkuMzU3NCAwIDAwLS4yNjIyLjE0MDdsLTEuMDc2Mi0uNDkxYS4zNDYyLjM0NjIgMCAwMC0uNDkuMzI4bC4wNDE2IDEuMTg0NWE3Ljk4MjYgNy45ODI2IDAgMDAtLjIyNzguMTg3M0wzLjg0MTMgMy40MjVhLjM0NzIuMzQ3MiAwIDAwLS40MTcxLjQxNzFsLjI3MTMgMS4xNTMxYy0uMDYyOC4wNzUtLjEyNTUuMTUwOS0uMTg2My4yMjY4bC0xLjE4NDUtLjA0MTVhLjM0NjIuMzQ2MiAwIDAwLS4zMjguNDlsLjQ5MSAxLjA3NjFhOS4xNjcgOS4xNjcgMCAwMC0uMTQwNy4yNjIybC0xLjE2NjIuMTg5NGEuMzQ4My4zNDgzIDAgMDAtLjIyNTguNTQ0NmwuNjkwNC45NTg3YTEzLjMwMyAxMy4zMDMgMCAwMC0uMDg3LjI4NTVsLTEuMTA2NS40MTRhLjM0ODMuMzQ4MyAwIDAwLS4xMTU1LjU3ODFsLjg2NTYuODA3YTkuMjkzNiA5LjI5MzYgMCAwMC0uMDI4My4yOTM1bC0xLjAwNzMuNjIzNmEuMzQ0Mi4zNDQyIDAgMDAwIC41ODkybDEuMDA3My42MjM2Yy4wMDguMDk4Mi4wMTgyLjE5NjQuMDI4My4yOTM2bC0uODY1Ni44MDc5YS4zNDYyLjM0NjIgMCAwMC4xMTU1LjU3OGwxLjEwNjUuNDE0MWMuMDI3My4wOTYyLjA1NjcuMTkxNC4wODcuMjg1NWwtLjY5MDQuOTU4N2EuMzQ1Mi4zNDUyIDAgMDAuMjI2OC41NDQ3bDEuMTY2Mi4xODkzYy4wNDU2LjA4OC4wOTIyLjE3NTEuMTQwOC4yNjIybC0uNDkxIDEuMDc2MmEuMzQ2Mi4zNDYyIDAgMDAuMzI4LjQ5bDEuMTgzNC0uMDQxNWMuMDYxOC4wNzY5LjEyMzUuMTUyOC4xODczLjIyNzdsLS4yNzEzIDEuMTU0MWEuMzQ2Mi4zNDYyIDAgMDAuNDE3MS40MTYxbDEuMTUzLS4yNzEzYy4wNzUuMDYzOC4xNTEuMTI1NS4yMjc5LjE4NjNsLS4wNDE1IDEuMTg0NWEuMzQ0Mi4zNDQyIDAgMDAuNDkuMzI3bDEuMDc2MS0uNDljLjA4Ny4wNDg2LjE3NDEuMDk1MS4yNjIyLjE0MDdsLjE5MDMgMS4xNjYyYS4zNDgzLjM0ODMgMCAwMC41NDQ3LjIyNjhsLjk1ODctLjY5MDRhOS4yOTkgOS4yOTkgMCAwMC4yODU1LjA4N2wuNDE0IDEuMTA2NmEuMzQ1Mi4zNDUyIDAgMDAuNTc4MS4xMTU0bC44MDc5LS44NjU2Yy4wOTcyLjAxMTEuMTk1NC4wMjAzLjI5MzYuMDI5NGwuNjIzNiAxLjAwNzNhLjM0NzIuMzQ3MiAwIDAwLjU4OTIgMGwuNjIzNi0xLjAwNzNjLjA5ODItLjAwOTEuMTk2NC0uMDE4My4yOTM2LS4wMjk0bC44MDY5Ljg2NTZhLjM0ODMuMzQ4MyAwIDAwLjU3OC0uMTE1NGwuNDE0MS0xLjEwNjZhOC40NjI2IDguNDYyNiAwIDAwLjI4NTUtLjA4N2wuOTU4Ny42OTA0YS4zNDUyLjM0NTIgMCAwMC41NDQ3LS4yMjY4bC4xOTAzLTEuMTY2MmMuMDg4LS4wNDU2LjE3NTEtLjA5MzEuMjYyMi0uMTQwN2wxLjA3NjIuNDlhLjM0NzIuMzQ3MiAwIDAwLjQ5LS4zMjdsLS4wNDE1LTEuMTg0NWE2LjcyNjcgNi43MjY3IDAgMDAuMjI2Ny0uMTg2M2wxLjE1MzEuMjcxM2EuMzQ3Mi4zNDcyIDAgMDAuNDE3MS0uNDE2bC0uMjcxMy0xLjE1NDJjLjA2MjgtLjA3NDkuMTI1NS0uMTUwOC4xODYzLS4yMjc4bDEuMTg0NS4wNDE1YS4zNDQyLjM0NDIgMCAwMC4zMjgtLjQ5bC0uNDktMS4wNzZjLjA0NzUtLjA4NzIuMDk1MS0uMTc0Mi4xNDA3LS4yNjIzbDEuMTY2Mi0uMTg5M2EuMzQ4My4zNDgzIDAgMDAuMjI1OC0uNTQ0N2wtLjY5MDQtLjk1ODcuMDg3LS4yODU1IDEuMTA2Ni0uNDE0YS4zNDYyLjM0NjIgMCAwMC4xMTU0LS41NzgxbC0uODY1Ni0uODA3OWMuMDEwMS0uMDk3Mi4wMjAyLS4xOTU0LjAyODMtLjI5MzZsMS4wMDczLS42MjM2YS4zNDQyLjM0NDIgMCAwMDAtLjU4OTJ6bS02Ljc0MTMgOC4zNTUxYS43MTM4LjcxMzggMCAwMS4yOTg2LTEuMzk2LjcxNC43MTQgMCAxMS0uMjk5NyAxLjM5NnptLS4zNDIyLTIuMzE0MmEuNjQ5LjY0OSAwIDAwLS43NzE1LjVsLS4zNTczIDEuNjY4NWMtMS4xMDM1LjUwMS0yLjMyODUuNzc5NS0zLjYxOTMuNzc5NWE4LjczNjggOC43MzY4IDAgMDEtMy42OTUxLS44MTRsLS4zNTc0LTEuNjY4NGEuNjQ4LjY0OCAwIDAwLS43NzE0LS40OTlsLTEuNDczLjMxNThhOC43MjE2IDguNzIxNiAwIDAxLS43NjEzLS44OThoNy4xNjc2Yy4wODEgMCAuMTM1Ni0uMDE0MS4xMzU2LS4wODh2LTIuNTM2YzAtLjA3NC0uMDUzNi0uMDg4MS0uMTM1Ni0uMDg4MWgtMi4wOTY2di0xLjYwNzdoMi4yNjc3Yy4yMDY1IDAgMS4xMDY1LjA1ODcgMS4zOTQgMS4yMDg4LjA5MDEuMzUzMy4yODc1IDEuNTA0NC40MjMyIDEuODcyOS4xMzQ2LjQxMy42ODMzIDEuMjM4MSAxLjI2ODUgMS4yMzgxaDMuNTcxNmEuNzQ5Mi43NDkyIDAgMDAuMTI5Ni0uMDEzMSA4Ljc4NzQgOC43ODc0IDAgMDEtLjgxMTkuOTUyNnpNNi44MzY5IDIwLjAyNGEuNzE0LjcxNCAwIDExLS4yOTk3LTEuMzk2LjcxNC43MTQgMCAwMS4yOTk3IDEuMzk2ek00LjExNzcgOC45OTcyYS43MTM3LjcxMzcgMCAxMS0xLjMwNC41NzkxLjcxMzcuNzEzNyAwIDAxMS4zMDQtLjU3OXptLS44MzUyIDEuOTgxM2wxLjUzNDctLjY4MjRhLjY1LjY1IDAgMDAuMzMtLjg1ODVsLS4zMTU4LS43MTQ3aDEuMjQzMnY1LjYwMjVIMy41NjY5YTguNzc1MyA4Ljc3NTMgMCAwMS0uMjgzNC0zLjM0OHptNi43MzQzLS41NDM3VjguNzgzNmgyLjk2MDFjLjE1MyAwIDEuMDc5Mi4xNzcyIDEuMDc5Mi44Njk3IDAgLjU3NS0uNzEwNy43ODE1LTEuMjk0OC43ODE1em0xMC43NTc0IDEuNDg2MmMwIC4yMTg3LS4wMDguNDM2My0uMDI0My42NTFoLS45Yy0uMDkgMC0uMTI2NS4wNTg2LS4xMjY1LjE0Nzd2LjQxM2MwIC45NzMtLjU0ODcgMS4xODQ2LTEuMDI5NiAxLjIzODItLjQ1NzYuMDUxNy0uOTY0OC0uMTkxMy0xLjAyNzUtLjQ3MTctLjI3MDQtMS41MTg2LS43MTk4LTEuODQzNi0xLjQzMDUtMi40MDM0Ljg4MTctLjU1OTkgMS43OTktMS4zODYgMS43OTktMi40OTE1IDAtMS4xOTM2LS44MTktMS45NDU4LTEuMzc2OS0yLjMxNTMtLjc4MjUtLjUxNjMtMS42NDkxLS42MTk1LTEuODgzLS42MTk1SDUuNDY4MmE4Ljc2NTEgOC43NjUxIDAgMDE0LjkwNy0yLjc2OTlsMS4wOTc0IDEuMTUxYS42NDguNjQ4IDAgMDAuOTE4Mi4wMjEzbDEuMjI3LTEuMTc0M2E4Ljc3NTMgOC43NzUzIDAgMDE2LjAwNDQgNC4yNzYybC0uODQwMyAxLjg5ODJhLjY1Mi42NTIgMCAwMC4zMy44NTg1bDEuNjE3OC43MTg4Yy4wMjgzLjI4NzUuMDQyNS41NzcuMDQyNS44NzE3em0tOS4zMDA2LTkuNTk5M2EuNzEyOC43MTI4IDAgMTEuOTg0IDEuMDMxNi43MTM3LjcxMzcgMCAwMS0uOTg0LTEuMDMxNnptOC4zMzg5IDYuNzFhLjcxMDcuNzEwNyAwIDAxLjkzOTUtLjM2MjUuNzEzNy43MTM3IDAgMTEtLjk0MDUuMzYzNXoiLz48L3N2Zz4="/><a target="_blank" href="https://google.com"><rect width="102" height="28" fill="rgba(0,0,0,0)"/><text transform="scale(.1)" x="595" y="175" textLength="610" fill="#fff">BUILDING</text></a><a target="_blank" href="https://example.com"><rect width="58" height="28" x="102" fill="rgba(0,0,0,0)"/><text transform="scale(.1)" x="1310" y="175" textLength="340" fill="#333" font-weight="bold">PASS</text></a></g></svg>"##;
1328 std::fs::write("badge.svg", &svg).unwrap();
1329 std::fs::write("badge_expected.svg", expected).unwrap();
1330 assert_eq!(
1331 svg, expected,
1332 "SVG rendering for ForTheBadge did not match expected output"
1333 );
1334 assert!(!svg.is_empty(), "SVG rendering for ForTheBadge failed");
1335 }
1336
1337 #[test]
1338 fn test_named_color() {
1339 let params = BadgeParams {
1340 style: BadgeStyle::FlatSquare,
1341 label: Some("status"),
1342 message: Some("ok"),
1343 label_color: Some("brightgreen"),
1344 message_color: Some("blue"),
1345 link: None,
1346 extra_link: None,
1347 logo: None,
1348 logo_color: None,
1349 };
1350 let svg = render_badge_svg(¶ms);
1351 assert!(
1352 svg.contains("fill=\"#4c1\""),
1353 "Named color brightgreen not correctly mapped"
1354 );
1355 assert!(
1356 svg.contains("fill=\"#007ec6\""),
1357 "Named color blue not correctly mapped"
1358 );
1359 }
1360
1361 #[test]
1362 fn test_alias_color() {
1363 let params = BadgeParams {
1364 style: BadgeStyle::FlatSquare,
1365 label: Some("status"),
1366 message: Some("ok"),
1367 label_color: Some("gray"),
1368 message_color: Some("critical"),
1369 link: None,
1370 extra_link: None,
1371 logo: None,
1372 logo_color: None,
1373 };
1374 let svg = render_badge_svg(¶ms);
1375 assert!(
1376 svg.contains("fill=\"#555\""),
1377 "Alias gray not correctly mapped"
1378 );
1379 assert!(
1380 svg.contains("fill=\"#e05d44\""),
1381 "Alias critical not correctly mapped"
1382 );
1383 }
1384
1385 #[test]
1386 fn test_hex_color() {
1387 let params = BadgeParams {
1388 style: BadgeStyle::FlatSquare,
1389 label: Some("hex"),
1390 message: Some("ok"),
1391 label_color: Some("#4c1"),
1392 message_color: Some("dfb317"),
1393 link: None,
1394 extra_link: None,
1395 logo: None,
1396 logo_color: None,
1397 };
1398 let svg = render_badge_svg(¶ms);
1399 assert!(
1400 svg.contains("fill=\"#4c1\""),
1401 "3-digit hex not correctly processed"
1402 );
1403 assert!(
1404 svg.contains("fill=\"#dfb317\""),
1405 "6-digit hex not correctly processed"
1406 );
1407 }
1408
1409 #[test]
1410 fn test_css_color() {
1411 let params = BadgeParams {
1412 style: BadgeStyle::FlatSquare,
1413 label: Some("css"),
1414 message: Some("ok"),
1415 label_color: Some("rgb(0,128,0)"),
1416 message_color: Some("hsl(120,100%,25%)"),
1417 link: None,
1418 extra_link: None,
1419 logo: None,
1420 logo_color: None,
1421 };
1422 let svg = render_badge_svg(¶ms);
1423 assert!(
1424 svg.contains(r#"fill="rgb(0,128,0)""#),
1425 "CSS rgb color not correctly processed"
1426 );
1427 assert!(
1428 svg.contains(r#"fill="hsl(120,100%,25%)""#),
1429 "CSS hsl color not correctly processed"
1430 );
1431 }
1432
1433 #[test]
1434 fn test_invalid_color_fallback() {
1435 let params = BadgeParams {
1436 style: BadgeStyle::FlatSquare,
1437 label: Some("bad"),
1438 message: Some("ok"),
1439 label_color: Some("notacolor"),
1440 message_color: Some(""),
1441 link: None,
1442 extra_link: None,
1443 logo: None,
1444 logo_color: None,
1445 };
1446 let svg = render_badge_svg(¶ms);
1447 assert!(
1448 svg.contains("fill=\"#555\""),
1449 "Invalid label_color did not fallback to default color"
1450 );
1451 assert!(
1452 svg.contains("fill=\"#007ec6\""),
1453 "Empty message_color did not fallback to default color"
1454 );
1455 }
1456
1457 #[test]
1458 fn test_color() {
1459 let c = Color::from_str("red").unwrap();
1461 println!("{:?}", c);
1462
1463 let c = Color::from_str("#ff0080").unwrap();
1465 println!("{:?}", c);
1466
1467 let c = Color::from_str("rgba(255,255,0,0.75)").unwrap();
1469 println!("{:?}", c);
1470
1471 let c = Color::from_str("hsl(120, 100%, 50%)").unwrap();
1473 println!("{:?}", c);
1474
1475 let c = Color::from_str("notexists").is_err();
1476 println!("{:?}", c);
1477 }
1478
1479 #[test]
1480 fn test_custom_svg_logo() {
1481 let custom_svg = "<svg width=\"377\" height=\"377\" viewBox=\"0 0 377 377\" xmlns=\"http://www.w3.org/2000/svg\">\
1482<circle cx=\"188.5\" cy=\"188.5\" r=\"172.5\" fill=\"#D9D9D9\" stroke=\"#1874A8\" stroke-width=\"32\"/>\
1483<circle cx=\"188.5\" cy=\"188.5\" r=\"172.5\" fill=\"#D9D9D9\" stroke=\"#1874A8\" stroke-width=\"32\"/>\
1484<path d=\"M289.352 113L307.016 140.904L223.944 189.416L307.016 237.032L288.712 265.832L189 203.88V175.208L289.352 113Z\" fill=\"#2E2E2E\"/>\
1485</svg>";
1486
1487 let params = BadgeParams {
1488 style: BadgeStyle::Flat,
1489 label: Some("custom"),
1490 message: Some("logo"),
1491 label_color: Some("#333"),
1492 message_color: Some("#4c1"),
1493 link: None,
1494 extra_link: None,
1495 logo: Some(custom_svg),
1496 logo_color: Some("#1874A8"),
1497 };
1498
1499 let svg = render_badge_svg(¶ms);
1500 assert!(svg.contains("custom"), "Badge should contain 'custom' text");
1502 assert!(svg.contains("logo"), "Badge should contain 'logo' text");
1503
1504 assert!(
1506 svg.contains("data:image/svg+xml;base64,"),
1507 "SVG should contain base64 encoded custom logo"
1508 );
1509
1510 let encoded_svg = base64::engine::general_purpose::STANDARD
1512 .encode(custom_svg.replace("<svg", &format!("<svg fill=\"{}\"", "#1874a8")));
1513 assert!(
1514 svg.contains(&encoded_svg),
1515 "SVG should contain custom logo with applied color"
1516 );
1517
1518 assert!(!svg.is_empty(), "Generated SVG should not be empty");
1519 }
1520}