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 font width tables embedded at compile time.
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;
55use std::borrow::Cow;
56use std::str::FromStr;
57pub mod builder;
58pub mod measurer;
59mod xml_escape;
60use base64::Engine;
61use color_util::to_svg_color;
62use csscolorparser::Color;
63use serde::Deserialize;
64
65mod font_tables {
67 #![allow(clippy::approx_constant)]
69 include!(concat!(env!("OUT_DIR"), "/font_tables.rs"));
70}
71
72#[derive(Template)]
74#[template(path = "flat_badge_template.min.svg", escape = "svg")]
75struct FlatBadgeSvgTemplateContext<'a> {
76 logo_width: u32,
77 total_width: i32,
78 id_suffix: &'a str,
79 badge_height: i32,
80 accessible_text: &'a str,
81 left_width: i32,
82 right_width: i32,
83 label_color: &'a str,
84 message_color: &'a str,
85 font_family: &'a str,
86 font_size_scaled: i32,
87
88 label: &'a str,
89 label_x: f32,
90 label_width_scaled: i32,
91 label_text_color: &'a str,
92 label_shadow_color: &'a str,
93
94 message: &'a str,
95 message_x: f32,
96 message_shadow_color: &'a str,
97 message_text_color: &'a str,
98 message_width_scaled: i32,
99
100 link: &'a str,
101 extra_link: &'a str,
102
103 logo: &'a str,
104 rect_offset: i32,
105
106 message_link_x: i32,
107}
108#[derive(Template)]
110#[template(path = "flat_square_badge_template.min.svg", escape = "svg")]
111struct FlatSquareBadgeSvgTemplateContext<'a> {
112 logo_width: u32,
113 total_width: i32,
114 badge_height: i32,
115 accessible_text: &'a str,
116 left_width: i32,
117 right_width: i32,
118 label_color: &'a str,
119 message_color: &'a str,
120 font_family: &'a str,
121 font_size_scaled: i32,
122
123 label: &'a str,
124 label_x: f32,
125 label_width_scaled: i32,
126 label_text_color: &'a str,
127
128 message: &'a str,
129 message_x: f32,
130 message_text_color: &'a str,
131 message_width_scaled: i32,
132
133 link: &'a str,
134 extra_link: &'a str,
135 logo: &'a str,
136 rect_offset: i32,
137
138 message_link_x: i32,
139}
140#[derive(Template)]
142#[template(path = "plastic_badge_template.min.svg", escape = "svg")]
143struct PlasticBadgeSvgTemplateContext<'a> {
144 logo_width: u32,
145 total_width: i32,
146 id_suffix: &'a str,
147 accessible_text: &'a str,
148 left_width: i32,
149 right_width: i32,
150 label: &'a str,
152 label_x: f32,
153 label_text_length: i32,
154 label_text_color: &'a str,
155 label_shadow_color: &'a str,
156 message: &'a str,
157 message_x: f32,
158 message_text_length: i32,
159 message_text_color: &'a str,
160 message_shadow_color: &'a str,
161 label_color: &'a str,
162 message_color: &'a str,
163
164 link: &'a str,
165 extra_link: &'a str,
166
167 logo: &'a str,
168 rect_offset: i32,
169
170 message_link_x: i32,
171}
172
173#[derive(Template)]
175#[template(path = "social_badge_template.min.svg", escape = "svg")]
176struct SocialBadgeSvgTemplateContext<'a> {
177 logo_width: u32,
178 total_width: i32,
179 id_suffix: &'a str,
180 total_height: i32,
181 internal_height: u32,
182 accessible_text: &'a str,
183 label_rect_width: i32,
184 message_bubble_main_x: f32,
185 message_rect_width: u32,
186 message_bubble_notch_x: i32,
187 label_text_x: f32,
188 label_text_length: u32,
189 label: &'a str,
190 message_text_x: f32,
191 message_text_length: u32,
192 message: &'a str,
193
194 link: &'a str,
195 extra_link: &'a str,
196
197 logo: &'a str,
198}
199
200#[derive(Template)]
202#[template(path = "for_the_badge_template.min.svg", escape = "svg")]
203struct ForTheBadgeSvgTemplateContext<'a> {
204 logo_width: u32,
205 total_width: f64,
207
208 accessible_text: &'a str,
210
211 has_label_rect: bool,
213 left_width: f64,
214 right_width: f64,
215
216 label_color: &'a str,
218 message_color: &'a str,
219
220 font_family: &'a str,
222 font_size: i32,
223
224 label: &'a str,
226 label_x: f64,
227 label_width_scaled: f64,
228 label_text_color: &'a str,
229
230 message: &'a str,
232 message_x: f64,
233 message_text_color: &'a str,
234 message_width_scaled: f64,
235
236 link: &'a str,
238 extra_link: &'a str,
239
240 logo: &'a str,
242 logo_x: f64,
243}
244
245mod color_util {
249 use csscolorparser::Color;
250 use std::borrow::Cow;
251 use std::str::FromStr;
252
253 fn named_color_hex(name: &str) -> Option<&'static str> {
255 Some(match name {
256 "brightgreen" => "#4c1",
257 "green" => "#97ca00",
258 "yellow" => "#dfb317",
259 "yellowgreen" => "#a4a61d",
260 "orange" => "#fe7d37",
261 "red" => "#e05d44",
262 "blue" => "#007ec6",
263 "grey" => "#555",
264 "lightgrey" => "#9f9f9f",
265 _ => return None,
266 })
267 }
268
269 fn alias_target(name: &str) -> Option<&'static str> {
271 Some(match name {
272 "gray" => "grey",
273 "lightgray" => "lightgrey",
274 "critical" => "red",
275 "important" => "orange",
276 "success" => "brightgreen",
277 "informational" => "blue",
278 "inactive" => "lightgrey",
279 _ => return None,
280 })
281 }
282
283 pub fn is_valid_hex(s: &str) -> bool {
285 let s = s.trim_start_matches('#');
286 let len = s.len();
287 (len == 3 || len == 6) && s.chars().all(|c| c.is_ascii_hexdigit())
288 }
289
290 pub fn to_svg_color(color: &str) -> Option<Cow<'_, str>> {
294 let color = color.trim();
295 if color.is_empty() {
296 return None;
297 }
298 let lower = if color.bytes().any(|b| b.is_ascii_uppercase()) {
301 Cow::Owned(color.to_ascii_lowercase())
302 } else {
303 Cow::Borrowed(color)
304 };
305 if let Some(hex) = named_color_hex(&lower) {
306 return Some(Cow::Borrowed(hex));
307 }
308 if let Some(alias) = alias_target(&lower) {
309 return named_color_hex(alias).map(Cow::Borrowed);
310 }
311 if is_valid_hex(&lower) {
312 return Some(if lower.starts_with('#') {
313 lower
314 } else {
315 Cow::Owned(format!("#{lower}"))
316 });
317 }
318 if Color::from_str(&lower).is_ok() {
319 return Some(lower);
320 }
321 None
322 }
323}
324pub trait FontMetrics {
326 fn get_text_width_px(&self, text: &str, font_family: &str) -> f32;
328}
329
330#[derive(Eq, PartialEq, Hash, Clone, Copy, Debug)]
332pub enum Font {
333 VerdanaNormal11,
335 HelveticaBold11,
337 VerdanaNormal10,
339 VerdanaBold10,
341}
342
343pub fn get_text_width(text: &str, font: Font) -> f64 {
348 use crate::measurer::CharWidthMeasurer;
349 use std::sync::LazyLock;
350
351 static VERDANA_11_N: LazyLock<CharWidthMeasurer> =
352 LazyLock::new(|| CharWidthMeasurer::from_sorted_static(&font_tables::VERDANA_11_NORMAL));
353 static HELVETICA_11_B: LazyLock<CharWidthMeasurer> =
354 LazyLock::new(|| CharWidthMeasurer::from_sorted_static(&font_tables::HELVETICA_11_BOLD));
355 static VERDANA_10_N: LazyLock<CharWidthMeasurer> =
356 LazyLock::new(|| CharWidthMeasurer::from_sorted_static(&font_tables::VERDANA_10_NORMAL));
357 static VERDANA_10_B: LazyLock<CharWidthMeasurer> =
358 LazyLock::new(|| CharWidthMeasurer::from_sorted_static(&font_tables::VERDANA_10_BOLD));
359
360 match font {
361 Font::VerdanaNormal11 => VERDANA_11_N.width_of(text, true),
362 Font::HelveticaBold11 => HELVETICA_11_B.width_of(text, true),
363 Font::VerdanaNormal10 => VERDANA_10_N.width_of(text, true),
364 Font::VerdanaBold10 => VERDANA_10_B.width_of(text, true),
365 }
366}
367macro_rules! round_up_to_odd_float {
368 ($func:ident, $float:ty) => {
369 fn $func(n: $float) -> u32 {
370 let n_rounded = n.floor() as u32;
371 if n_rounded % 2 == 0 {
372 n_rounded + 1
373 } else {
374 n_rounded
375 }
376 }
377 };
378}
379
380round_up_to_odd_float!(round_up_to_odd_f64, f64);
381const BADGE_HEIGHT: u32 = 20;
382const HORIZONTAL_PADDING: u32 = 5;
383const FONT_FAMILY: &str = "Verdana,Geneva,DejaVu Sans,sans-serif";
384const FONT_SIZE_SCALED: u32 = 110;
385const FONT_SCALE_UP_FACTOR: u32 = 10;
386pub fn colors_for_background(hex: &str) -> (&'static str, &'static str) {
394 let hex = hex.trim_start_matches('#');
396 let expand_nibble = |c: u8| -> u8 {
398 let v = match c {
399 b'0'..=b'9' => c - b'0',
400 b'a'..=b'f' => c - b'a' + 10,
401 b'A'..=b'F' => c - b'A' + 10,
402 _ => 0,
403 };
404 (v << 4) | v
405 };
406 let (r, g, b) = match hex.len() {
408 3 => {
409 let bytes = hex.as_bytes();
410 (
411 expand_nibble(bytes[0]),
412 expand_nibble(bytes[1]),
413 expand_nibble(bytes[2]),
414 )
415 }
416 6 => (
417 u8::from_str_radix(&hex[0..2], 16).unwrap_or(0),
418 u8::from_str_radix(&hex[2..4], 16).unwrap_or(0),
419 u8::from_str_radix(&hex[4..6], 16).unwrap_or(0),
420 ),
421 _ => (0, 0, 0), };
423 let brightness = (0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32) / 255.0;
425 if brightness <= 0.69 {
426 ("#fff", "#010101")
427 } else {
428 ("#333", "#ccc")
429 }
430}
431pub(crate) fn preferred_width_of(text: &str, font: Font) -> u32 {
432 round_up_to_odd_f64(get_text_width(text, font))
433}
434
435fn colors_for_color_or(color: &str, fallback: &str) -> (&'static str, &'static str) {
441 if color_util::is_valid_hex(color) {
442 return colors_for_background(color);
443 }
444 let hex = Color::from_str(color)
445 .unwrap_or_else(|_| Color::from_str(fallback).unwrap())
446 .to_css_hex();
447 colors_for_background(&hex)
448}
449
450fn capitalize(s: &str) -> String {
457 match s.chars().next() {
458 Some(first) => {
459 let mut out: String = first.to_uppercase().collect();
460 out.push_str(&s[first.len_utf8()..]);
461 out
462 }
463 None => String::new(),
464 }
465}
466
467struct FlatLayout<'a> {
470 accessible_text: String,
471 label: &'a str,
472 left_width: i32,
473 right_width: i32,
474 total_width: i32,
475 label_x: f32,
476 label_width_scaled: i32,
477 message_x: f32,
478 message_width_scaled: i32,
479 rect_offset: i32,
480 message_link_x: i32,
481 label_text_color: &'static str,
482 label_shadow_color: &'static str,
483 message_text_color: &'static str,
484 message_shadow_color: &'static str,
485}
486
487#[allow(clippy::too_many_arguments)]
488fn compute_flat_layout<'a>(
489 label: Option<&'a str>,
490 message: &str,
491 label_color: &str,
492 message_color: &str,
493 has_label_color: bool,
494 has_logo: bool,
495 total_logo_width: u32,
496 extra_link_not_empty_str: bool,
497 extra_link: &str,
498) -> FlatLayout<'a> {
499 let accessible_text = create_accessible_text(label, message);
500 let has_label_content = label.is_some() && !label.unwrap().is_empty();
501 let has_label = has_label_content || has_label_color;
502 let label_margin = total_logo_width + 1;
503
504 let label_width = if has_label && label.is_some() {
505 preferred_width_of(label.unwrap_or_default(), Font::VerdanaNormal11)
506 } else {
507 0
508 };
509
510 let mut left_width = if has_label {
511 (label_width + 2 * HORIZONTAL_PADDING + total_logo_width) as i32
512 } else {
513 0
514 };
515
516 if has_label && label.is_some() && label.unwrap().is_empty() {
517 left_width -= 1;
518 }
519 let message_width = preferred_width_of(message, Font::VerdanaNormal11);
520
521 let offset = if label.is_none() && has_logo {
522 -3i32
523 } else {
524 0
525 };
526
527 let left_width = left_width + offset;
528 let mut message_margin: i32 = left_width - if message.is_empty() { 0 } else { 1 };
529 if !has_label {
530 if has_logo {
531 message_margin += (total_logo_width + HORIZONTAL_PADDING) as i32;
532 } else {
533 message_margin += 1;
534 }
535 }
536
537 let mut right_width = (message_width + 2 * HORIZONTAL_PADDING) as i32;
538 if has_logo && !has_label {
539 right_width += total_logo_width as i32
540 + if !message.is_empty() {
541 (HORIZONTAL_PADDING - 1) as i32
542 } else {
543 0i32
544 };
545 }
546
547 let label_x = 10.0
548 * (label_margin as f32 + (0.5 * label_width as f32) + HORIZONTAL_PADDING as f32)
549 + offset as f32;
550 let label_width_scaled = (label_width * 10) as i32;
551 let total_width = left_width + right_width;
552
553 let right_width = right_width + if !has_label_color { offset } else { 0 };
554 let (label_text_color, label_shadow_color) = colors_for_color_or(label_color, "#555");
555 let (message_text_color, message_shadow_color) = colors_for_color_or(message_color, "#007ec6");
556 let rect_offset = if has_logo { 19 } else { 0 };
557
558 let message_link_x = if has_logo && !has_label && extra_link_not_empty_str {
559 total_logo_width as i32 + HORIZONTAL_PADDING as i32
560 } else {
561 left_width
562 };
563
564 let has_extra_link = !extra_link.is_empty();
565 let message_x =
566 10.0 * (message_margin as f32 + (0.5 * message_width as f32) + HORIZONTAL_PADDING as f32);
567 let message_link_x = message_link_x
568 + if !has_label && has_extra_link {
569 offset
570 } else {
571 0
572 };
573 let message_width_scaled = (message_width * 10) as i32;
574 let left_width = left_width.max(0);
575
576 FlatLayout {
577 accessible_text,
578 label: label.unwrap_or(""),
579 left_width,
580 right_width,
581 total_width,
582 label_x,
583 label_width_scaled,
584 message_x,
585 message_width_scaled,
586 rect_offset,
587 message_link_x,
588 label_text_color,
589 label_shadow_color,
590 message_text_color,
591 message_shadow_color,
592 }
593}
594
595#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
596#[serde(rename_all = "kebab-case")]
597pub enum BadgeStyle {
611 #[default]
613 Flat,
614 FlatSquare,
616 Plastic,
618 Social,
620 ForTheBadge,
622}
623
624pub fn default_message_color() -> &'static str {
626 "#007ec6"
627}
628
629pub fn default_label_color() -> &'static str {
631 "#555"
632}
633
634#[derive(Deserialize, Debug)]
635pub struct BadgeParams<'a> {
668 #[serde(default)]
669 pub style: BadgeStyle,
671 pub label: Option<&'a str>,
673 pub message: Option<&'a str>,
675 pub label_color: Option<&'a str>,
677 pub message_color: Option<&'a str>,
679 pub link: Option<&'a str>,
681 pub extra_link: Option<&'a str>,
683 pub logo: Option<&'a str>,
685 pub logo_color: Option<&'a str>,
687}
688
689#[derive(Deserialize, Debug, Clone, Default)]
702pub struct BadgeParamsOwned {
703 #[serde(default)]
704 pub style: BadgeStyle,
706 pub label: Option<String>,
708 pub message: Option<String>,
710 pub label_color: Option<String>,
712 pub message_color: Option<String>,
714 pub link: Option<String>,
716 pub extra_link: Option<String>,
718 pub logo: Option<String>,
720 pub logo_color: Option<String>,
722}
723
724impl BadgeParamsOwned {
725 pub fn as_params(&self) -> BadgeParams<'_> {
727 BadgeParams {
728 style: self.style,
729 label: self.label.as_deref(),
730 message: self.message.as_deref(),
731 label_color: self.label_color.as_deref(),
732 message_color: self.message_color.as_deref(),
733 link: self.link.as_deref(),
734 extra_link: self.extra_link.as_deref(),
735 logo: self.logo.as_deref(),
736 logo_color: self.logo_color.as_deref(),
737 }
738 }
739
740 pub fn render(&self) -> String {
742 render_badge_svg(&self.as_params())
743 }
744}
745
746#[derive(Debug, Default, Clone)]
757#[non_exhaustive]
758pub struct RenderOptions<'a> {
759 pub id_suffix: &'a str,
766
767 pub logo_width: Option<u32>,
772}
773
774impl<'a> RenderOptions<'a> {
775 pub fn id_suffix(mut self, id_suffix: &'a str) -> Self {
777 self.id_suffix = id_suffix;
778 self
779 }
780
781 pub fn logo_width(mut self, logo_width: u32) -> Self {
783 self.logo_width = Some(logo_width);
784 self
785 }
786}
787
788fn is_safe_link(link: &str) -> bool {
796 let trimmed = link.trim_matches(|c: char| c.is_whitespace() || (c as u32) < 0x20);
799 let Some(colon) = trimmed.find(':') else {
800 return true;
802 };
803 let scheme = &trimmed[..colon];
804 if scheme.contains(['/', '?', '#']) {
807 return true;
808 }
809 let scheme: String = scheme
811 .chars()
812 .filter(|c| !c.is_whitespace())
813 .map(|c| c.to_ascii_lowercase())
814 .collect();
815 !matches!(scheme.as_str(), "javascript" | "vbscript" | "data")
816}
817
818fn sanitize_id_suffix(raw: &str) -> String {
825 raw.chars()
826 .filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
827 .collect()
828}
829
830pub fn render_badge_svg(params: &BadgeParams) -> String {
856 render_badge_svg_with(params, &RenderOptions::default())
857}
858
859pub fn render_badge_svg_with(params: &BadgeParams, options: &RenderOptions) -> String {
879 render_badge_svg_impl(params, options)
880 .unwrap_or_else(|e| format!("<!-- Askama render error: {e} -->"))
881}
882
883pub fn try_render_badge_svg(params: &BadgeParams) -> Result<String, RenderError> {
889 try_render_badge_svg_with(params, &RenderOptions::default())
890}
891
892pub fn try_render_badge_svg_with(
894 params: &BadgeParams,
895 options: &RenderOptions,
896) -> Result<String, RenderError> {
897 render_badge_svg_impl(params, options).map_err(|e| RenderError(e.to_string()))
898}
899
900#[derive(Debug)]
902pub struct RenderError(String);
903
904impl std::fmt::Display for RenderError {
905 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
906 write!(f, "badge template rendering failed: {}", self.0)
907 }
908}
909
910impl std::error::Error for RenderError {}
911
912fn render_reserving<T: Template>(ctx: &T, logo_len: usize) -> Result<String, askama::Error> {
918 let mut buf = String::with_capacity(T::SIZE_HINT + logo_len);
919 ctx.render_into(&mut buf)?;
920 Ok(buf)
921}
922
923fn build_logo_data_uri(logo: &str, logo_color: &str) -> String {
926 let icon_svg: &str = if logo.starts_with("<svg") {
927 logo
928 } else {
929 #[cfg(feature = "simple-icons")]
930 {
931 simpleicons::Icon::get_svg(logo).unwrap_or_default()
932 }
933 #[cfg(not(feature = "simple-icons"))]
935 {
936 ""
937 }
938 };
939 if !icon_svg.starts_with("<svg") {
940 return icon_svg.to_string();
941 }
942
943 let svg_tag_end = icon_svg.find('>').unwrap_or(0);
945 let has_fill_in_svg_tag = icon_svg[..svg_tag_end].contains("fill=");
946 let logo_svg = if !has_fill_in_svg_tag && !logo_color.is_empty() {
947 Cow::Owned(icon_svg.replace("<svg", format!("<svg fill=\"{logo_color}\"").as_str()))
948 } else {
949 Cow::Borrowed(icon_svg)
950 };
951
952 const PREFIX: &str = "data:image/svg+xml;base64,";
953 let mut uri = String::with_capacity(PREFIX.len() + logo_svg.len().div_ceil(3) * 4);
954 uri.push_str(PREFIX);
955 base64::engine::general_purpose::STANDARD.encode_string(logo_svg.as_ref(), &mut uri);
956 uri
957}
958
959mod logo_cache {
964 use std::cell::RefCell;
965 use std::rc::Rc;
966
967 const CAPACITY: usize = 16;
968
969 thread_local! {
970 static CACHE: RefCell<Vec<(String, String, Rc<str>)>> = const { RefCell::new(Vec::new()) };
972 }
973
974 pub fn get_or_insert(logo: &str, color: &str, build: fn(&str, &str) -> String) -> Rc<str> {
975 CACHE.with_borrow_mut(|entries| {
976 if let Some(i) = entries.iter().position(|(l, c, _)| l == logo && c == color) {
977 let entry = entries.remove(i);
978 let uri = Rc::clone(&entry.2);
979 entries.push(entry);
980 return uri;
981 }
982 let uri: Rc<str> = Rc::from(build(logo, color));
983 if entries.len() == CAPACITY {
984 entries.remove(0);
985 }
986 entries.push((logo.to_owned(), color.to_owned(), Rc::clone(&uri)));
987 uri
988 })
989 }
990}
991
992fn render_badge_svg_impl(
993 params: &BadgeParams,
994 options: &RenderOptions,
995) -> Result<String, askama::Error> {
996 let id_suffix = sanitize_id_suffix(options.id_suffix);
997 let id_suffix = id_suffix.as_str();
998 let BadgeParams {
999 style,
1000 label,
1001 message,
1002 label_color,
1003 message_color,
1004 link,
1005 extra_link,
1006 logo,
1007 logo_color,
1008 } = params;
1009 let label = *label;
1010 let default_logo_color = if *style == BadgeStyle::Social {
1011 "#000000"
1012 } else {
1013 "whitesmoke"
1014 };
1015
1016 let logo_color = logo_color.unwrap_or(default_logo_color);
1017 let logo_color = to_svg_color(logo_color).unwrap_or(Cow::Borrowed(default_logo_color));
1018
1019 let logo_src = logo.map(str::trim).unwrap_or("");
1020 let logo_uri = (!logo_src.is_empty())
1021 .then(|| logo_cache::get_or_insert(logo_src, &logo_color, build_logo_data_uri));
1022 let logo = logo_uri.as_deref().unwrap_or("");
1023 let has_logo = !logo.is_empty();
1024 let logo_width = options.logo_width.unwrap_or(14);
1025 let mut logo_padding = 3;
1026 if label.is_some() && label.unwrap().is_empty() {
1027 logo_padding = 0;
1028 }
1029
1030 let total_logo_width = if has_logo {
1031 logo_width + logo_padding
1032 } else {
1033 0
1034 };
1035
1036 let has_label_color = !label_color.unwrap_or("").is_empty();
1037 let message_color = message_color.unwrap_or(default_message_color());
1038 let message_color = to_svg_color(message_color).unwrap_or(Cow::Borrowed("#007ec6"));
1039
1040 let label_color = match (
1041 label.unwrap_or("").is_empty(),
1042 label_color.unwrap_or("").is_empty(),
1043 ) {
1044 (true, true) if has_logo => "#555",
1045 (true, true) => message_color.as_ref(),
1046 (_, _) => label_color.unwrap_or(default_label_color()),
1047 };
1048
1049 let binding = to_svg_color(label_color).unwrap_or(Cow::Borrowed("#555"));
1050 let label_color = binding.as_ref();
1051
1052 let message_color = message_color.as_ref();
1053 let message = message.unwrap_or("");
1054 let link = link.filter(|l| is_safe_link(l));
1056 let extra_link = extra_link.filter(|l| is_safe_link(l));
1057 let link = link.unwrap_or("");
1058 let extra_link_not_empty_str = extra_link.is_none() || !extra_link.unwrap().is_empty();
1059 let extra_link = extra_link.unwrap_or("");
1060 match style {
1061 BadgeStyle::Flat => {
1062 let l = compute_flat_layout(
1063 label,
1064 message,
1065 label_color,
1066 message_color,
1067 has_label_color,
1068 has_logo,
1069 total_logo_width,
1070 extra_link_not_empty_str,
1071 extra_link,
1072 );
1073 let ctx = FlatBadgeSvgTemplateContext {
1074 logo_width,
1075 font_family: FONT_FAMILY,
1076 id_suffix,
1077 accessible_text: l.accessible_text.as_str(),
1078 badge_height: BADGE_HEIGHT as i32,
1079 left_width: l.left_width,
1080 right_width: l.right_width,
1081 total_width: l.total_width,
1082 label_color,
1083 message_color,
1084 font_size_scaled: FONT_SIZE_SCALED as i32,
1085 label: l.label,
1086 label_x: l.label_x,
1087 label_width_scaled: l.label_width_scaled,
1088 label_text_color: l.label_text_color,
1089 label_shadow_color: l.label_shadow_color,
1090 message_x: l.message_x,
1091 message_shadow_color: l.message_shadow_color,
1092 message_text_color: l.message_text_color,
1093 message_width_scaled: l.message_width_scaled,
1094 message,
1095 link,
1096 extra_link,
1097 logo,
1098 rect_offset: l.rect_offset,
1099 message_link_x: l.message_link_x,
1100 };
1101 render_reserving(&ctx, logo.len())
1102 }
1103 BadgeStyle::FlatSquare => {
1104 let l = compute_flat_layout(
1105 label,
1106 message,
1107 label_color,
1108 message_color,
1109 has_label_color,
1110 has_logo,
1111 total_logo_width,
1112 extra_link_not_empty_str,
1113 extra_link,
1114 );
1115 let ctx = FlatSquareBadgeSvgTemplateContext {
1116 logo_width,
1117 font_family: FONT_FAMILY,
1118 accessible_text: l.accessible_text.as_str(),
1119 badge_height: BADGE_HEIGHT as i32,
1120 left_width: l.left_width,
1121 right_width: l.right_width,
1122 total_width: l.total_width,
1123 label_color,
1124 message_color,
1125 font_size_scaled: FONT_SIZE_SCALED as i32,
1126 label: l.label,
1127 label_x: l.label_x,
1128 label_width_scaled: l.label_width_scaled,
1129 label_text_color: l.label_text_color,
1130 message_x: l.message_x,
1131 message_text_color: l.message_text_color,
1132 message_width_scaled: l.message_width_scaled,
1133 message,
1134 link,
1135 extra_link,
1136 logo,
1137 rect_offset: l.rect_offset,
1138 message_link_x: l.message_link_x,
1139 };
1140 render_reserving(&ctx, logo.len())
1141 }
1142 BadgeStyle::Plastic => {
1143 let l = compute_flat_layout(
1144 label,
1145 message,
1146 label_color,
1147 message_color,
1148 has_label_color,
1149 has_logo,
1150 total_logo_width,
1151 extra_link_not_empty_str,
1152 extra_link,
1153 );
1154 let ctx = PlasticBadgeSvgTemplateContext {
1155 logo_width,
1156 total_width: l.total_width,
1157 id_suffix,
1158 left_width: l.left_width,
1159 right_width: l.right_width,
1160 accessible_text: l.accessible_text.as_str(),
1161 label: l.label,
1162 label_x: l.label_x,
1163 label_text_length: l.label_width_scaled,
1164 label_text_color: l.label_text_color,
1165 label_shadow_color: l.label_shadow_color,
1166 message,
1167 message_x: l.message_x,
1168 message_text_length: l.message_width_scaled,
1169 message_text_color: l.message_text_color,
1170 message_shadow_color: l.message_shadow_color,
1171 label_color,
1172 message_color,
1173 link,
1174 extra_link,
1175 logo,
1176 rect_offset: l.rect_offset,
1177 message_link_x: l.message_link_x,
1178 };
1179 render_reserving(&ctx, logo.len())
1180 }
1181 BadgeStyle::Social => {
1182 let label_is_none = label.is_none();
1183
1184 let offset = if label_is_none && has_logo {
1185 -3i32
1186 } else {
1187 0i32
1188 };
1189
1190 let label = capitalize(label.unwrap_or(""));
1191 let label_str = label.as_str();
1192 let accessible_text = create_accessible_text(Some(label_str), message);
1193 let internal_height = 19;
1194 let label_horizontal_padding = 5;
1195 let message_horizontal_padding = 4;
1196 let horizontal_gutter = 6;
1197
1198 let label_text_width = preferred_width_of(label_str, Font::HelveticaBold11);
1199
1200 let label_rect_width =
1201 (label_text_width + total_logo_width + 2 * label_horizontal_padding) as i32
1202 + offset;
1203
1204 let message_text_width = preferred_width_of(message, Font::HelveticaBold11);
1205
1206 let message_rect_width = message_text_width + 2 * message_horizontal_padding;
1207 let has_message = !message.is_empty();
1208
1209 let message_bubble_main_x = label_rect_width as f32 + horizontal_gutter as f32 + 0.5;
1210 let message_bubble_notch_x = label_rect_width + horizontal_gutter;
1211 let label_text_x = FONT_SCALE_UP_FACTOR as f32
1212 * (total_logo_width as f32
1213 + label_text_width as f32 / 2.0
1214 + label_horizontal_padding as f32
1215 + offset as f32);
1216 let message_text_x = FONT_SCALE_UP_FACTOR as f32
1217 * (label_rect_width as f32
1218 + horizontal_gutter as f32
1219 + message_rect_width as f32 / 2.0);
1220 let message_text_length = FONT_SCALE_UP_FACTOR * message_text_width;
1221 let label_text_length = FONT_SCALE_UP_FACTOR * label_text_width;
1222
1223 let left_width = label_rect_width + 1;
1224 let right_width = if has_message {
1225 horizontal_gutter + message_rect_width as i32
1226 } else {
1227 0
1228 };
1229
1230 let total_width = left_width + right_width;
1231
1232 let ctx = SocialBadgeSvgTemplateContext {
1233 logo_width,
1234 total_width,
1235 id_suffix,
1236 total_height: BADGE_HEIGHT as i32,
1237 internal_height,
1238 accessible_text: accessible_text.as_str(),
1239 message_rect_width,
1240 message_bubble_main_x,
1241 message_bubble_notch_x,
1242 label_text_length,
1243 label: label_str,
1244 message,
1245 label_text_x,
1246 message_text_x,
1247 message_text_length,
1248 label_rect_width,
1249 link,
1250 extra_link,
1251 logo,
1252 };
1253 render_reserving(&ctx, logo.len())
1254 }
1255 BadgeStyle::ForTheBadge => {
1256 let label = label.unwrap_or("").to_uppercase();
1258 let message = message.to_uppercase();
1259 let accessible_text = create_accessible_text(Some(label.as_str()), message.as_str());
1260 let font_size = 10;
1261 let letter_spacing = 1.25f64;
1262 let logo_text_gutter = 6.0f64;
1263 let logo_margin = 9.0f64;
1264 let logo_width = logo_width as f64;
1265 let label_text_width = if !label.is_empty() {
1268 get_text_width(&label, Font::VerdanaNormal10).trunc()
1269 + letter_spacing * label.encode_utf16().count() as f64
1270 } else {
1271 0.0
1272 };
1273 let message_text_width = if !message.is_empty() {
1274 get_text_width(&message, Font::VerdanaBold10).trunc()
1275 + letter_spacing * message.encode_utf16().count() as f64
1276 } else {
1277 0.0
1278 };
1279 let has_label = !label.is_empty();
1280 let no_text = !has_label && message.is_empty();
1281 let need_label_rect = has_label || (!logo.is_empty() && has_label_color);
1284 let gutter = if no_text {
1285 logo_text_gutter - logo_margin
1286 } else {
1287 logo_text_gutter
1288 };
1289 let text_margin = 12.0f64;
1290
1291 let (logo_min_x, label_text_min_x) = if !logo.is_empty() {
1293 (logo_margin, logo_margin + logo_width + gutter)
1294 } else {
1295 (0.0, text_margin)
1296 };
1297
1298 let (label_rect_width, message_text_min_x, message_rect_width) = if need_label_rect {
1300 if has_label {
1301 (
1302 label_text_min_x + label_text_width + text_margin,
1303 label_text_min_x + label_text_width + text_margin + text_margin,
1304 2.0 * text_margin + message_text_width,
1305 )
1306 } else {
1307 (
1308 2.0 * logo_margin + logo_width,
1309 2.0 * logo_margin + logo_width + text_margin,
1310 2.0 * text_margin + message_text_width,
1311 )
1312 }
1313 } else if !logo.is_empty() {
1314 (
1315 0.0,
1316 text_margin + logo_width + gutter,
1317 2.0 * text_margin + logo_width + gutter + message_text_width,
1318 )
1319 } else {
1320 (0.0, text_margin, 2.0 * text_margin + message_text_width)
1321 };
1322 let total_width = label_rect_width + message_rect_width;
1323
1324 let message_mid_x = message_text_min_x + 0.5 * message_text_width;
1325 let label_mid_x = label_text_min_x + 0.5 * label_text_width;
1326
1327 let (label_text_color, _) = colors_for_color_or(label_color, "#555");
1328 let (message_text_color, _) = colors_for_color_or(message_color, "#007ec6");
1329
1330 let ctx = ForTheBadgeSvgTemplateContext {
1331 logo_width: logo_width as u32,
1332 total_width,
1333 accessible_text: accessible_text.as_str(),
1334 has_label_rect: need_label_rect,
1335 left_width: label_rect_width,
1336 right_width: message_rect_width,
1337 label_color,
1338 message_color,
1339 font_family: FONT_FAMILY,
1340 font_size: font_size * FONT_SCALE_UP_FACTOR as i32,
1341 label: label.as_str(),
1342 label_x: label_mid_x * FONT_SCALE_UP_FACTOR as f64,
1343 label_width_scaled: label_text_width * FONT_SCALE_UP_FACTOR as f64,
1344 label_text_color,
1345 message: message.as_str(),
1346 message_x: message_mid_x * FONT_SCALE_UP_FACTOR as f64,
1347 message_text_color,
1348 message_width_scaled: message_text_width * FONT_SCALE_UP_FACTOR as f64,
1349 link,
1350 extra_link,
1351 logo,
1352 logo_x: logo_min_x,
1353 };
1354 render_reserving(&ctx, logo.len())
1355 }
1356 }
1357}
1358
1359fn create_accessible_text(label: Option<&str>, message: &str) -> String {
1360 let use_label = match label {
1361 Some(l) if !l.is_empty() => Some(l),
1362 _ => None,
1363 };
1364 let label_len = use_label.map_or(0, |l| l.len() + 2); let mut buf = String::with_capacity(label_len + message.len());
1366 if let Some(label) = use_label {
1367 buf.push_str(label);
1368 buf.push_str(": ");
1369 }
1370 buf.push_str(message);
1371 buf
1372}
1373
1374#[cfg(test)]
1375mod tests {
1376 use csscolorparser::Color;
1377 use pretty_assertions::assert_eq;
1378 use std::str::FromStr;
1379
1380 use super::*;
1381 #[test]
1382 fn test_svg() {
1383 let params = BadgeParams {
1385 style: BadgeStyle::FlatSquare,
1386 label: Some("build"),
1387 message: Some("passing"),
1388 label_color: Some("#333"),
1389 message_color: Some("#4c1"),
1390 link: None,
1391 extra_link: None,
1392 logo: None,
1393 logo_color: None,
1394 };
1395 let svg = render_badge_svg(¶ms);
1396 assert!(!svg.is_empty(), "SVG rendering failed");
1397 }
1398
1399 #[test]
1400 fn text_for_the_badge() {
1401 let params = BadgeParams {
1403 style: BadgeStyle::ForTheBadge,
1404 label: Some("building"),
1405 message: Some("pass"),
1406 label_color: Some("#555"),
1407 message_color: Some("#fff"),
1408 link: Some("https://google.com"),
1409 extra_link: Some("https://example.com"),
1410 logo: Some("rust"),
1411 logo_color: Some("blue"),
1412 };
1413 let svg = render_badge_svg(¶ms);
1414 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">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" font-weight="bold" fill="#333">PASS</text></a></g></svg>"##;
1415 assert_eq!(
1416 svg, expected,
1417 "SVG rendering for ForTheBadge did not match expected output"
1418 );
1419 assert!(!svg.is_empty(), "SVG rendering for ForTheBadge failed");
1420 }
1421
1422 #[test]
1423 fn test_named_color() {
1424 let params = BadgeParams {
1425 style: BadgeStyle::FlatSquare,
1426 label: Some("status"),
1427 message: Some("ok"),
1428 label_color: Some("brightgreen"),
1429 message_color: Some("blue"),
1430 link: None,
1431 extra_link: None,
1432 logo: None,
1433 logo_color: None,
1434 };
1435 let svg = render_badge_svg(¶ms);
1436 assert!(
1437 svg.contains("fill=\"#4c1\""),
1438 "Named color brightgreen not correctly mapped"
1439 );
1440 assert!(
1441 svg.contains("fill=\"#007ec6\""),
1442 "Named color blue not correctly mapped"
1443 );
1444 }
1445
1446 #[test]
1447 fn test_alias_color() {
1448 let params = BadgeParams {
1449 style: BadgeStyle::FlatSquare,
1450 label: Some("status"),
1451 message: Some("ok"),
1452 label_color: Some("gray"),
1453 message_color: Some("critical"),
1454 link: None,
1455 extra_link: None,
1456 logo: None,
1457 logo_color: None,
1458 };
1459 let svg = render_badge_svg(¶ms);
1460 assert!(
1461 svg.contains("fill=\"#555\""),
1462 "Alias gray not correctly mapped"
1463 );
1464 assert!(
1465 svg.contains("fill=\"#e05d44\""),
1466 "Alias critical not correctly mapped"
1467 );
1468 }
1469
1470 #[test]
1471 fn test_hex_color() {
1472 let params = BadgeParams {
1473 style: BadgeStyle::FlatSquare,
1474 label: Some("hex"),
1475 message: Some("ok"),
1476 label_color: Some("#4c1"),
1477 message_color: Some("dfb317"),
1478 link: None,
1479 extra_link: None,
1480 logo: None,
1481 logo_color: None,
1482 };
1483 let svg = render_badge_svg(¶ms);
1484 assert!(
1485 svg.contains("fill=\"#4c1\""),
1486 "3-digit hex not correctly processed"
1487 );
1488 assert!(
1489 svg.contains("fill=\"#dfb317\""),
1490 "6-digit hex not correctly processed"
1491 );
1492 }
1493
1494 #[test]
1495 fn test_css_color() {
1496 let params = BadgeParams {
1497 style: BadgeStyle::FlatSquare,
1498 label: Some("css"),
1499 message: Some("ok"),
1500 label_color: Some("rgb(0,128,0)"),
1501 message_color: Some("hsl(120,100%,25%)"),
1502 link: None,
1503 extra_link: None,
1504 logo: None,
1505 logo_color: None,
1506 };
1507 let svg = render_badge_svg(¶ms);
1508 assert!(
1509 svg.contains(r#"fill="rgb(0,128,0)""#),
1510 "CSS rgb color not correctly processed"
1511 );
1512 assert!(
1513 svg.contains(r#"fill="hsl(120,100%,25%)""#),
1514 "CSS hsl color not correctly processed"
1515 );
1516 }
1517
1518 #[test]
1519 fn test_invalid_color_fallback() {
1520 let params = BadgeParams {
1521 style: BadgeStyle::FlatSquare,
1522 label: Some("bad"),
1523 message: Some("ok"),
1524 label_color: Some("notacolor"),
1525 message_color: Some(""),
1526 link: None,
1527 extra_link: None,
1528 logo: None,
1529 logo_color: None,
1530 };
1531 let svg = render_badge_svg(¶ms);
1532 assert!(
1533 svg.contains("fill=\"#555\""),
1534 "Invalid label_color did not fallback to default color"
1535 );
1536 assert!(
1537 svg.contains("fill=\"#007ec6\""),
1538 "Empty message_color did not fallback to default color"
1539 );
1540 }
1541
1542 #[test]
1543 fn test_color() {
1544 let c = Color::from_str("red").unwrap();
1546 println!("{c:?}");
1547
1548 let c = Color::from_str("#ff0080").unwrap();
1550 println!("{c:?}");
1551
1552 let c = Color::from_str("rgba(255,255,0,0.75)").unwrap();
1554 println!("{c:?}");
1555
1556 let c = Color::from_str("hsl(120, 100%, 50%)").unwrap();
1558 println!("{c:?}");
1559
1560 let c = Color::from_str("notexists").is_err();
1561 println!("{c:?}");
1562 }
1563
1564 #[test]
1565 fn test_id_suffix() {
1566 use crate::builder::Badge;
1567 for style in [BadgeStyle::Flat, BadgeStyle::Plastic] {
1568 let svg = render_badge_svg_with(
1569 &BadgeParams {
1570 style,
1571 label: Some("a"),
1572 message: Some("b"),
1573 label_color: None,
1574 message_color: None,
1575 link: None,
1576 extra_link: None,
1577 logo: None,
1578 logo_color: None,
1579 },
1580 &RenderOptions::default().id_suffix("x1"),
1581 );
1582 assert!(svg.contains(r##"id="sx1""##), "{style:?}: {svg}");
1583 assert!(svg.contains(r##"url(#sx1)"##), "{style:?}");
1584 assert!(svg.contains(r##"id="rx1""##), "{style:?}");
1585 assert!(svg.contains(r##"url(#rx1)"##), "{style:?}");
1586 assert!(!svg.contains(r##"id="s" "##), "{style:?}");
1587 }
1588
1589 let svg = Badge::style(BadgeStyle::Social)
1590 .label("a")
1591 .message("b")
1592 .id_suffix("x1")
1593 .build();
1594 for needle in [
1595 r##"id="ax1""##,
1596 r##"id="bx1""##,
1597 r##"id="llinkx1""##,
1598 r##"id="rlinkx1""##,
1599 r##"url(#ax1)"##,
1600 "a:hover #llinkx1{fill:url(#bx1);stroke:#ccc}a:hover #rlinkx1{fill:#4183c4}",
1601 ] {
1602 assert!(svg.contains(needle), "missing {needle} in {svg}");
1603 }
1604
1605 let svg = render_badge_svg_with(
1607 &BadgeParams {
1608 style: BadgeStyle::Flat,
1609 label: Some("a"),
1610 message: Some("b"),
1611 label_color: None,
1612 message_color: None,
1613 link: None,
1614 extra_link: None,
1615 logo: None,
1616 logo_color: None,
1617 },
1618 &RenderOptions::default().id_suffix("x\"><script>1"),
1619 );
1620 assert!(svg.contains(r##"id="sxscript1""##));
1621 let default_svg = render_badge_svg(&BadgeParams {
1622 style: BadgeStyle::Flat,
1623 label: Some("a"),
1624 message: Some("b"),
1625 label_color: None,
1626 message_color: None,
1627 link: None,
1628 extra_link: None,
1629 logo: None,
1630 logo_color: None,
1631 });
1632 assert!(default_svg.contains(r##"id="s""##));
1633 }
1634
1635 #[test]
1636 fn test_logo_width() {
1637 let params = BadgeParams {
1638 style: BadgeStyle::Flat,
1639 label: Some("build"),
1640 message: Some("passing"),
1641 label_color: None,
1642 message_color: None,
1643 link: None,
1644 extra_link: None,
1645 logo: Some("rust"),
1646 logo_color: None,
1647 };
1648 let default_svg = render_badge_svg(¶ms);
1649 let wide_svg = render_badge_svg_with(¶ms, &RenderOptions::default().logo_width(30));
1650 assert!(default_svg.contains(r#"width="14" height="14""#));
1651 assert!(wide_svg.contains(r#"width="30" height="14""#));
1652
1653 let width_of = |svg: &str| -> u32 {
1654 let start = svg.find("width=\"").unwrap() + 7;
1655 let end = svg[start..].find('"').unwrap() + start;
1656 svg[start..end].parse().unwrap()
1657 };
1658 assert_eq!(width_of(&wide_svg), width_of(&default_svg) + 16);
1660 }
1661
1662 #[test]
1663 fn test_custom_svg_logo() {
1664 let custom_svg = "<svg width=\"377\" height=\"377\" viewBox=\"0 0 377 377\" xmlns=\"http://www.w3.org/2000/svg\">\
1665<circle cx=\"188.5\" cy=\"188.5\" r=\"172.5\" fill=\"#D9D9D9\" stroke=\"#1874A8\" stroke-width=\"32\"/>\
1666<circle cx=\"188.5\" cy=\"188.5\" r=\"172.5\" fill=\"#D9D9D9\" stroke=\"#1874A8\" stroke-width=\"32\"/>\
1667<path d=\"M289.352 113L307.016 140.904L223.944 189.416L307.016 237.032L288.712 265.832L189 203.88V175.208L289.352 113Z\" fill=\"#2E2E2E\"/>\
1668</svg>";
1669
1670 let params = BadgeParams {
1671 style: BadgeStyle::Flat,
1672 label: Some("custom"),
1673 message: Some("logo"),
1674 label_color: Some("#333"),
1675 message_color: Some("#4c1"),
1676 link: None,
1677 extra_link: None,
1678 logo: Some(custom_svg),
1679 logo_color: Some("#1874A8"),
1680 };
1681
1682 let svg = render_badge_svg(¶ms);
1683 assert!(svg.contains("custom"), "Badge should contain 'custom' text");
1685 assert!(svg.contains("logo"), "Badge should contain 'logo' text");
1686
1687 assert!(
1689 svg.contains("data:image/svg+xml;base64,"),
1690 "SVG should contain base64 encoded custom logo"
1691 );
1692
1693 let encoded_svg = base64::engine::general_purpose::STANDARD
1695 .encode(custom_svg.replace("<svg", &format!("<svg fill=\"{}\"", "#1874a8")));
1696 assert!(
1697 svg.contains(&encoded_svg),
1698 "SVG should contain custom logo with applied color"
1699 );
1700
1701 assert!(!svg.is_empty(), "Generated SVG should not be empty");
1702 }
1703
1704 const ALL_STYLES: [BadgeStyle; 5] = [
1705 BadgeStyle::Flat,
1706 BadgeStyle::FlatSquare,
1707 BadgeStyle::Plastic,
1708 BadgeStyle::Social,
1709 BadgeStyle::ForTheBadge,
1710 ];
1711
1712 fn render(style: BadgeStyle, label: &str, message: &str, link: Option<&str>) -> String {
1713 render_badge_svg(&BadgeParams {
1714 style,
1715 label: Some(label),
1716 message: Some(message),
1717 label_color: None,
1718 message_color: None,
1719 link,
1720 extra_link: None,
1721 logo: None,
1722 logo_color: None,
1723 })
1724 }
1725
1726 #[test]
1727 fn test_text_is_xml_escaped() {
1728 for style in ALL_STYLES {
1731 let svg = render(style, "AT&T <3", "a\"b'c", None).to_lowercase();
1734 assert!(svg.contains("at&t <3"), "{style:?}: {svg}");
1735 assert!(svg.contains("a"b'c"), "{style:?}: {svg}");
1736 assert!(!svg.contains("at&t"), "{style:?}: {svg}");
1738 assert!(!svg.contains("<3"), "{style:?}: {svg}");
1739 }
1740 }
1741
1742 #[test]
1743 fn test_text_cannot_break_out_of_attribute_or_element() {
1744 for style in ALL_STYLES {
1745 let svg = render(
1746 style,
1747 "\" onload=\"PWN",
1748 "</text><script>x</script><text>",
1749 None,
1750 );
1751 assert!(!svg.contains("onload=\"PWN\""), "{style:?}: {svg}");
1752 assert!(!svg.contains("<script>"), "{style:?}: {svg}");
1753 }
1754 }
1755
1756 #[test]
1757 fn test_link_scheme_is_filtered() {
1758 for link in [
1759 "javascript:alert(1)",
1760 "JaVaScRiPt:alert(1)",
1761 " \t javascript:alert(1)",
1762 "java\tscript:alert(1)",
1763 "vbscript:msgbox(1)",
1764 "data:text/html,<script>x</script>",
1765 ] {
1766 assert!(!is_safe_link(link), "{link:?} should be rejected");
1767 for style in ALL_STYLES {
1768 let svg = render(style, "a", "b", Some(link));
1769 assert!(!svg.contains("href=\""), "{style:?} {link:?}: {svg}");
1770 }
1771 }
1772
1773 for link in [
1775 "https://example.com/x?a=1&b=2",
1776 "/relative/path",
1777 "#frag",
1778 "mailto:a@b.com",
1779 "/path:with:colon",
1780 ] {
1781 assert!(is_safe_link(link), "{link:?} should be allowed");
1782 }
1783 let svg = render(BadgeStyle::Flat, "a", "b", Some("https://e.com/?a=1&b=2"));
1784 assert!(svg.contains("href=\"https://e.com/?a=1&b=2\""), "{svg}");
1785 }
1786
1787 #[test]
1788 fn test_escaping_preserves_logo_and_font() {
1789 let svg = render_badge_svg(&BadgeParams {
1792 style: BadgeStyle::Flat,
1793 label: Some("a"),
1794 message: Some("b"),
1795 label_color: None,
1796 message_color: None,
1797 link: None,
1798 extra_link: None,
1799 logo: Some("rust"),
1800 logo_color: None,
1801 });
1802 assert!(svg.contains("href=\"data:image/svg+xml;base64,"), "{svg}");
1803 assert!(!svg.contains('&'), "logo/font must not be altered: {svg}");
1805 assert!(
1806 svg.contains(&format!("font-family=\"{FONT_FAMILY}\"")),
1807 "{svg}"
1808 );
1809 }
1810
1811 #[test]
1812 fn test_capitalize_only_touches_the_first_character() {
1813 assert_eq!(capitalize(""), "");
1814 assert_eq!(capitalize("abc DEF"), "Abc DEF");
1815 assert_eq!(capitalize("aBcD"), "ABcD");
1816 assert_eq!(capitalize("AT&T"), "AT&T");
1817 assert_eq!(capitalize("README"), "README");
1818 assert_eq!(capitalize("école TEST"), "École TEST");
1819 assert_eq!(capitalize("1st"), "1st");
1820 }
1821
1822 #[test]
1823 fn test_social_label_keeps_its_case() {
1824 let svg = render(BadgeStyle::Social, "AT&T", "PASSING", None);
1827 assert!(svg.contains("AT&T"), "{svg}");
1828 assert!(svg.contains("PASSING"), "{svg}");
1829 }
1830}