shields/
lib.rs

1#![doc = r#"
2# shields
3
4A Rust library for generating SVG badges, inspired by [shields.io](https://shields.io/).
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(&params);
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/// SVG rendering template context, fields must correspond to variables in badge_svg_template_askama.svg
64#[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/// flat-square SVG rendering template context
98#[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/// plastic SVG rendering template context
129#[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    // gradient
137    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/// social SVG rendering template context
160#[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/// for-the-badge SVG rendering template context
185#[derive(Template)]
186#[template(path = "for_the_badge_template.min.svg", escape = "none")]
187struct ForTheBadgeSvgTemplateContext<'a> {
188    // SVG dimensions
189    total_width: i32,
190
191    // Accessibility
192    accessible_text: &'a str,
193
194    // Layout dimensions
195    left_width: i32,
196    right_width: i32,
197
198    // Colors
199    label_color: &'a str,
200    message_color: &'a str,
201
202    // Font settings
203    font_family: &'a str,
204    font_size: i32,
205
206    // Label (left side)
207    label: &'a str,
208    label_x: f32,
209    label_width_scaled: i32,
210    label_text_color: &'a str,
211
212    // Message (right side)
213    message: &'a str,
214    message_x: f32,
215    message_text_color: &'a str,
216    message_width_scaled: i32,
217
218    // Links
219    link: &'a str,
220    extra_link: &'a str,
221
222    // Logo
223    logo: &'a str,
224    logo_x: i32,
225}
226
227// --- Color processing utility module ---
228// Supports standardization and SVG output of named colors, aliases, hex, and CSS color inputs
229
230mod 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    // Named color mapping
240    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    // Alias mapping
255    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    // 3/6 digit hex validation
268    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    // Simplified CSS color validation (supports rgb(a), hsl(a), common formats)
275    pub fn is_css_color(s: &str) -> bool {
276        Color::from_str(s).is_ok()
277    }
278
279    /// Standardizes color input, returning a string usable in SVG or None
280    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        // Check cache first
289        if let Some(cached) = {
290            let mut cache = CACHE.lock().unwrap();
291            cache.get(&key).cloned()
292        } {
293            return cached;
294        }
295        // Allocate only if there are uppercase letters
296        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    /// Outputs SVG-compatible color (hex string), prioritizing named colors and aliases, otherwise original
315    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}
338/// Font width calculation trait, to be implemented and injected by the main project
339pub trait FontMetrics {
340    /// Supports font-family fallback
341    fn get_text_width_px(&self, text: &str, font_family: &str) -> f32;
342}
343
344/// Font enumeration for supported fonts
345#[derive(Eq, PartialEq, Hash, Clone, Debug)]
346pub enum Font {
347    /// Verdana 11px Normal
348    VerdanaNormal11,
349    /// Helvetica 11px Bold
350    HelveticaBold11,
351    /// Verdana 10px Normal
352    VerdanaNormal10,
353    /// Verdana 10px Bold
354    VerdanaBold10,
355}
356
357/// Calculates the width of text in Verdana 11px (in pixels)
358///
359/// - Only the text needs to be passed in, the width table is loaded and reused internally
360/// - Efficient lazy initialization to avoid repeated IO
361/// - Can be directly used in scenarios like SVG badges
362pub fn get_text_width(text: &str, font: Font) -> f64 {
363    use crate::measurer::CharWidthMeasurer;
364    use once_cell::sync::Lazy;
365
366    // 在编译时直接将 JSON 文件内容作为字符串嵌入
367    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        // 从嵌入的字符串加载数据,而不是从文件系统
373        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        // 从嵌入的字符串加载数据
379        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;
418/// Dynamically calculates foreground and shadow colors based on background color (equivalent to JS colorsForBackground)
419///
420/// - Input: hex color string (supports 3/6 digits, e.g. "#4c1", "#007ec6")
421/// - Algorithm:
422///   1. Parses hex to RGB
423///   2. Calculates brightness = (0.299*R + 0.587*G + 0.114*B) / 255
424///   3. If brightness ≤ 0.69, returns ("#fff", "#010101"), otherwise ("#333", "#ccc")
425pub fn colors_for_background(hex: &str) -> (&'static str, &'static str) {
426    // Remove leading #
427    let hex = hex.trim_start_matches('#');
428    // Parse RGB
429    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), // Invalid input, return black
468    };
469    // W3C recommended brightness formula
470    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    // Create a cache that includes font information in the key
484    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")]
509/// Badge style variants supported by the shields crate.
510///
511/// - `Flat`: Modern flat style (default).
512/// - `FlatSquare`: Flat with square edges.
513/// - `Plastic`: Classic plastic style.
514/// - `Social`: Social badge style (e.g., GitHub social).
515/// - `ForTheBadge`: All-caps, bold, attention-grabbing style.
516///
517/// ## Example
518/// ```rust
519/// use shields::BadgeStyle;
520/// let style = BadgeStyle::Plastic;
521/// ```
522pub enum BadgeStyle {
523    /// Flat style, which is modern and minimalistic.
524    Flat,
525    /// Flat style, which is modern and minimalistic, but with square edges.
526    FlatSquare,
527    /// Plastic style, which has a glossy look.
528    Plastic,
529    /// Social badge style, typically used for GitHub or other social media badges.
530    Social,
531    /// For-the-badge style, which is bold and all-caps.
532    ForTheBadge,
533}
534
535impl Default for BadgeStyle {
536    /// Returns the default badge style (`Flat`).
537    fn default() -> Self {
538        BadgeStyle::Flat
539    }
540}
541
542/// Returns the default message color hex string (`#007ec6`).
543pub fn default_message_color() -> &'static str {
544    "#007ec6"
545}
546
547/// Returns the default label color hex string (`#555`).
548pub fn default_label_color() -> &'static str {
549    "#555"
550}
551
552#[derive(Deserialize, Debug)]
553/// Parameters for generating a badge SVG.
554///
555/// This struct is used to configure all aspects of a badge, including style, label, message, colors, links, and logo.
556///
557/// # Fields
558/// - `style`: Badge style variant (see [`BadgeStyle`]).
559/// - `label`: Optional label text (left side).
560/// - `message`: Optional message text (right side).
561/// - `label_color`: Optional label background color (hex, name, or alias).
562/// - `message_color`: Optional message background color (hex, name, or alias).
563/// - `link`: Optional main link URL.
564/// - `extra_link`: Optional secondary link URL.
565/// - `logo`: Optional logo name or SVG data.
566/// - `logo_color`: Optional logo color.
567///
568/// ## Example
569/// ```rust
570/// use shields::{BadgeParams, BadgeStyle, render_badge_svg};
571/// let params = BadgeParams {
572///     style: BadgeStyle::Flat,
573///     label: Some("build"),
574///     message: Some("passing"),
575///     label_color: Some("green"),
576///     message_color: Some("brightgreen"),
577///     link: Some("https://ci.example.com"),
578///     extra_link: None,
579///     logo: None,
580///     logo_color: None,
581/// };
582/// let svg = render_badge_svg(&params);
583/// assert!(svg.contains("passing"));
584/// ```
585pub struct BadgeParams<'a> {
586    #[serde(default)]
587    /// Badge style variant (default is `Flat`).
588    pub style: BadgeStyle,
589    /// Optional label text (left side).
590    pub label: Option<&'a str>,
591    /// Optional message text (right side).
592    pub message: Option<&'a str>,
593    /// Optional label color, defaults to `#555` (dark gray).
594    pub label_color: Option<&'a str>,
595    /// Optional message color, defaults to `#007ec6` (blue).
596    pub message_color: Option<&'a str>,
597    /// Optional main link, used for linking the badge to a URL.
598    pub link: Option<&'a str>,
599    /// Optional secondary link, used for social badges or additional information.
600    pub extra_link: Option<&'a str>,
601    /// Optional logo name (e.g., "github", "rust") or SVG data.
602    pub logo: Option<&'a str>,
603    /// Optional logo color, defaults to `#000000` for social badges, otherwise `whitesmoke`.
604    pub logo_color: Option<&'a str>,
605}
606
607/// Generate an SVG badge string from [`BadgeParams`].
608///
609/// # Arguments
610/// * `params` - Badge parameters (see [`BadgeParams`]).
611///
612/// # Returns
613/// SVG string representing the badge.
614///
615/// ## Example
616/// ```rust
617/// use shields::{BadgeParams, BadgeStyle, render_badge_svg};
618/// let params = BadgeParams {
619///     style: BadgeStyle::Flat,
620///     label: Some("build"),
621///     message: Some("passing"),
622///     label_color: Some("green"),
623///     message_color: Some("brightgreen"),
624///     link: Some("https://ci.example.com"),
625///     extra_link: None,
626///     logo: None,
627///     logo_color: None,
628/// };
629/// let svg = render_badge_svg(&params);
630/// assert!(svg.contains("passing"));
631/// ```
632pub 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 logo_color = logo_color.unwrap_or("#555");
662                // let icon = to_svg_color(logo_color).unwrap_or("#555".to_string());
663                let icon = logo;
664                let svg = simpleicons::Icon::get_svg(icon);
665                svg.unwrap_or_default()
666            }
667        }
668        None => "",
669    };
670    // 如果 logo 为 <svg 开头,则需要获取 base64 编码
671    let logo = if icon_svg.starts_with("<svg") {
672        // 只检查 <svg> 标签内是否有 fill 属性,且 logo_color 不为空,则添加 fill 属性
673        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            // label to uppercase
1163            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            // Logo positioning
1194            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            // Handle label and message rectangles
1201            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); // +2 for ": "
1277    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        // Test SVG rendering
1296        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(&params);
1308        assert!(!svg.is_empty(), "SVG rendering failed");
1309    }
1310
1311    #[test]
1312    fn text_for_the_badge() {
1313        // Test ForTheBadge style rendering
1314        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(&params);
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(&params);
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(&params);
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(&params);
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(&params);
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(&params);
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        // 解析名称
1460        let c = Color::from_str("red").unwrap();
1461        println!("{:?}", c);
1462
1463        // 解析HEX
1464        let c = Color::from_str("#ff0080").unwrap();
1465        println!("{:?}", c);
1466
1467        // 解析RGBA
1468        let c = Color::from_str("rgba(255,255,0,0.75)").unwrap();
1469        println!("{:?}", c);
1470
1471        // 解析HSL
1472        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(&params);
1500        // Test that the badge contains expected text
1501        assert!(svg.contains("custom"), "Badge should contain 'custom' text");
1502        assert!(svg.contains("logo"), "Badge should contain 'logo' text");
1503
1504        // Test that SVG contains custom logo (base64 encoded)
1505        assert!(
1506            svg.contains("data:image/svg+xml;base64,"),
1507            "SVG should contain base64 encoded custom logo"
1508        );
1509
1510        // Test that the logo color is applied to the custom SVG (in lowercase)
1511        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}