Skip to main content

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 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(&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;
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
65/// Font width tables generated at build time from `assets/fonts/*.json`.
66mod font_tables {
67    // Generated width data contains literals that happen to look like math constants
68    #![allow(clippy::approx_constant)]
69    include!(concat!(env!("OUT_DIR"), "/font_tables.rs"));
70}
71
72/// SVG rendering template context, fields must correspond to variables in badge_svg_template_askama.svg
73#[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/// flat-square SVG rendering template context
109#[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/// plastic SVG rendering template context
141#[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    // gradient
151    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/// social SVG rendering template context
174#[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/// for-the-badge SVG rendering template context
201#[derive(Template)]
202#[template(path = "for_the_badge_template.min.svg", escape = "svg")]
203struct ForTheBadgeSvgTemplateContext<'a> {
204    logo_width: u32,
205    // SVG dimensions (upstream keeps fractional widths for this style)
206    total_width: f64,
207
208    // Accessibility
209    accessible_text: &'a str,
210
211    // Layout dimensions
212    has_label_rect: bool,
213    left_width: f64,
214    right_width: f64,
215
216    // Colors
217    label_color: &'a str,
218    message_color: &'a str,
219
220    // Font settings
221    font_family: &'a str,
222    font_size: i32,
223
224    // Label (left side)
225    label: &'a str,
226    label_x: f64,
227    label_width_scaled: f64,
228    label_text_color: &'a str,
229
230    // Message (right side)
231    message: &'a str,
232    message_x: f64,
233    message_text_color: &'a str,
234    message_width_scaled: f64,
235
236    // Links
237    link: &'a str,
238    extra_link: &'a str,
239
240    // Logo
241    logo: &'a str,
242    logo_x: f64,
243}
244
245// --- Color processing utility module ---
246// Supports standardization and SVG output of named colors, aliases, hex, and CSS color inputs
247
248mod color_util {
249    use csscolorparser::Color;
250    use std::borrow::Cow;
251    use std::str::FromStr;
252
253    /// shields.io named color palette
254    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    /// Aliases resolving to named colors
270    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    // 3/6 digit hex validation
284    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    /// Outputs an SVG-compatible color: named colors and aliases become their hex value,
291    /// hex strings are normalized to a leading `#`, other valid CSS colors pass through
292    /// lowercased. Returns `None` for invalid input.
293    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        // Callers pass an already-lowercase color most of the time (`#4c1`, `blue`),
299        // and named colors resolve to static hex, so the common paths never allocate.
300        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}
324/// Font width calculation trait, to be implemented and injected by the main project
325pub trait FontMetrics {
326    /// Supports font-family fallback
327    fn get_text_width_px(&self, text: &str, font_family: &str) -> f32;
328}
329
330/// Font enumeration for supported fonts
331#[derive(Eq, PartialEq, Hash, Clone, Copy, Debug)]
332pub enum Font {
333    /// Verdana 11px Normal
334    VerdanaNormal11,
335    /// Helvetica 11px Bold
336    HelveticaBold11,
337    /// Verdana 10px Normal
338    VerdanaNormal10,
339    /// Verdana 10px Bold
340    VerdanaBold10,
341}
342
343/// Calculates the width of text in the given font (in pixels)
344///
345/// - Width tables are generated at compile time from the JSON sources; no runtime parsing or IO
346/// - Can be directly used in scenarios like SVG badges
347pub 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;
386/// Dynamically calculates foreground and shadow colors based on background color (equivalent to JS colorsForBackground)
387///
388/// - Input: hex color string (supports 3/6 digits, e.g. "#4c1", "#007ec6")
389/// - Algorithm:
390///   1. Parses hex to RGB
391///   2. Calculates brightness = (0.299*R + 0.587*G + 0.114*B) / 255
392///   3. If brightness ≤ 0.69, returns ("#fff", "#010101"), otherwise ("#333", "#ccc")
393pub fn colors_for_background(hex: &str) -> (&'static str, &'static str) {
394    // Remove leading #
395    let hex = hex.trim_start_matches('#');
396    // Expands a single hex digit to a full byte, e.g. 'c' -> 0xcc; invalid digits count as 0
397    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    // Parse RGB
407    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), // Invalid input, return black
422    };
423    // W3C recommended brightness formula
424    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
435/// Foreground/shadow pair for `color`, falling back to `fallback` when it does not parse.
436///
437/// Colors reaching here have already been normalized by `to_svg_color`, so they are
438/// usually plain hex. `colors_for_background` expands 3-digit hex to the same bytes
439/// `to_css_hex` would produce, so hex inputs can skip the CSS parse entirely.
440fn 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
450/// Uppercases the first character and leaves the rest alone, the way the social
451/// style upstream does.
452///
453/// Lowercasing the tail as well — which askama's `capitalize` filter does —
454/// would turn `AT&T` into `At&t` and `README` into `Readme`, and the narrower
455/// lowercase letters would shrink the badge below upstream's width.
456fn 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
467/// Shared horizontal layout for the flat, flat-square and plastic styles.
468/// These styles differ only in chrome (gradients, shadows), not in geometry.
469struct 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")]
597/// Badge style variants supported by the shields crate.
598///
599/// - `Flat`: Modern flat style (default).
600/// - `FlatSquare`: Flat with square edges.
601/// - `Plastic`: Classic plastic style.
602/// - `Social`: Social badge style (e.g., GitHub social).
603/// - `ForTheBadge`: All-caps, bold, attention-grabbing style.
604///
605/// ## Example
606/// ```rust
607/// use shields::BadgeStyle;
608/// let style = BadgeStyle::Plastic;
609/// ```
610pub enum BadgeStyle {
611    /// Flat style, which is modern and minimalistic.
612    #[default]
613    Flat,
614    /// Flat style, which is modern and minimalistic, but with square edges.
615    FlatSquare,
616    /// Plastic style, which has a glossy look.
617    Plastic,
618    /// Social badge style, typically used for GitHub or other social media badges.
619    Social,
620    /// For-the-badge style, which is bold and all-caps.
621    ForTheBadge,
622}
623
624/// Returns the default message color hex string (`#007ec6`).
625pub fn default_message_color() -> &'static str {
626    "#007ec6"
627}
628
629/// Returns the default label color hex string (`#555`).
630pub fn default_label_color() -> &'static str {
631    "#555"
632}
633
634#[derive(Deserialize, Debug)]
635/// Parameters for generating a badge SVG.
636///
637/// This struct is used to configure all aspects of a badge, including style, label, message, colors, links, and logo.
638///
639/// # Fields
640/// - `style`: Badge style variant (see [`BadgeStyle`]).
641/// - `label`: Optional label text (left side).
642/// - `message`: Optional message text (right side).
643/// - `label_color`: Optional label background color (hex, name, or alias).
644/// - `message_color`: Optional message background color (hex, name, or alias).
645/// - `link`: Optional main link URL.
646/// - `extra_link`: Optional secondary link URL.
647/// - `logo`: Optional logo name or SVG data.
648/// - `logo_color`: Optional logo color.
649///
650/// ## Example
651/// ```rust
652/// use shields::{BadgeParams, BadgeStyle, render_badge_svg};
653/// let params = BadgeParams {
654///     style: BadgeStyle::Flat,
655///     label: Some("build"),
656///     message: Some("passing"),
657///     label_color: Some("green"),
658///     message_color: Some("brightgreen"),
659///     link: Some("https://ci.example.com"),
660///     extra_link: None,
661///     logo: None,
662///     logo_color: None,
663/// };
664/// let svg = render_badge_svg(&params);
665/// assert!(svg.contains("passing"));
666/// ```
667pub struct BadgeParams<'a> {
668    #[serde(default)]
669    /// Badge style variant (default is `Flat`).
670    pub style: BadgeStyle,
671    /// Optional label text (left side).
672    pub label: Option<&'a str>,
673    /// Optional message text (right side).
674    pub message: Option<&'a str>,
675    /// Optional label color, defaults to `#555` (dark gray).
676    pub label_color: Option<&'a str>,
677    /// Optional message color, defaults to `#007ec6` (blue).
678    pub message_color: Option<&'a str>,
679    /// Optional main link, used for linking the badge to a URL.
680    pub link: Option<&'a str>,
681    /// Optional secondary link, used for social badges or additional information.
682    pub extra_link: Option<&'a str>,
683    /// Optional logo name (e.g., "github", "rust") or SVG data.
684    pub logo: Option<&'a str>,
685    /// Optional logo color, defaults to `#000000` for social badges, otherwise `whitesmoke`.
686    pub logo_color: Option<&'a str>,
687}
688
689/// Owned variant of [`BadgeParams`], for callers that cannot borrow —
690/// typically deserializing from an HTTP query string or JSON body.
691///
692/// ## Example
693/// ```rust
694/// use shields::{BadgeParamsOwned, BadgeStyle};
695/// let params: BadgeParamsOwned = serde_json::from_str(
696///     r#"{"style":"flat","label":"build","message":"passing"}"#,
697/// ).unwrap();
698/// let svg = params.render();
699/// assert!(svg.contains("passing"));
700/// ```
701#[derive(Deserialize, Debug, Clone, Default)]
702pub struct BadgeParamsOwned {
703    #[serde(default)]
704    /// Badge style variant (default is `Flat`).
705    pub style: BadgeStyle,
706    /// Optional label text (left side).
707    pub label: Option<String>,
708    /// Optional message text (right side).
709    pub message: Option<String>,
710    /// Optional label color, defaults to `#555` (dark gray).
711    pub label_color: Option<String>,
712    /// Optional message color, defaults to `#007ec6` (blue).
713    pub message_color: Option<String>,
714    /// Optional main link, used for linking the badge to a URL.
715    pub link: Option<String>,
716    /// Optional secondary link, used for social badges or additional information.
717    pub extra_link: Option<String>,
718    /// Optional logo name (e.g., "github", "rust") or SVG data.
719    pub logo: Option<String>,
720    /// Optional logo color, defaults to `#000000` for social badges, otherwise `whitesmoke`.
721    pub logo_color: Option<String>,
722}
723
724impl BadgeParamsOwned {
725    /// Borrows these owned parameters as a [`BadgeParams`].
726    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    /// Renders the badge SVG (see [`render_badge_svg`]).
741    pub fn render(&self) -> String {
742        render_badge_svg(&self.as_params())
743    }
744}
745
746/// Additional rendering options that extend [`BadgeParams`] without breaking
747/// its literal-construction API.
748///
749/// Construct with [`RenderOptions::default`] and set fields through the
750/// builder-style methods:
751///
752/// ```rust
753/// use shields::RenderOptions;
754/// let opts = RenderOptions::default().id_suffix("badge1");
755/// ```
756#[derive(Debug, Default, Clone)]
757#[non_exhaustive]
758pub struct RenderOptions<'a> {
759    /// Suffix appended to every SVG element id (`#s`, `#r`, `#llink`, ...).
760    ///
761    /// SVGs embedded inline in the same HTML page share one id namespace, so
762    /// two badges both defining `id="s"` corrupt each other's gradients. Give
763    /// each badge a unique suffix to avoid collisions. Only `[A-Za-z0-9_-]`
764    /// characters are used; anything else is stripped.
765    pub id_suffix: &'a str,
766
767    /// Width of the rendered logo in pixels (default 14).
768    ///
769    /// Mirrors badge-maker's `logoWidth` option. The logo height stays 14;
770    /// widen this for logos with a wide aspect ratio so they are not squeezed.
771    pub logo_width: Option<u32>,
772}
773
774impl<'a> RenderOptions<'a> {
775    /// Sets the id suffix (see the field documentation).
776    pub fn id_suffix(mut self, id_suffix: &'a str) -> Self {
777        self.id_suffix = id_suffix;
778        self
779    }
780
781    /// Sets the rendered logo width in pixels (see the field documentation).
782    pub fn logo_width(mut self, logo_width: u32) -> Self {
783        self.logo_width = Some(logo_width);
784        self
785    }
786}
787
788/// Rejects `href` values whose scheme executes script when the badge is opened
789/// or embedded as a document.
790///
791/// XML escaping keeps a link inside its attribute, but `javascript:alert(1)`
792/// needs no special character to fire — the scheme itself is the payload. Only
793/// the script-bearing schemes are refused; everything else (absolute URLs,
794/// relative paths, fragments, `mailto:`, …) passes through untouched.
795fn is_safe_link(link: &str) -> bool {
796    // Browsers ignore leading whitespace and C0 controls before the scheme, and
797    // match it case-insensitively, so strip and fold before comparing.
798    let trimmed = link.trim_matches(|c: char| c.is_whitespace() || (c as u32) < 0x20);
799    let Some(colon) = trimmed.find(':') else {
800        // No scheme at all: a relative path or fragment, which cannot execute.
801        return true;
802    };
803    let scheme = &trimmed[..colon];
804    // A '/', '?' or '#' before the colon means it was never a scheme
805    // ("/a:b" is a path), so the value is relative and safe.
806    if scheme.contains(['/', '?', '#']) {
807        return true;
808    }
809    // Browsers also skip embedded tabs/newlines inside the scheme ("java\tscript:").
810    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
818/// Strips characters outside `[A-Za-z0-9_-]`.
819///
820/// The suffix is not only interpolated into `id="…"` but also into the
821/// `url(#…)` references pointing at it, where escaping would break the link
822/// rather than protect it. Restricting the character set keeps both sides
823/// valid without relying on the escaper.
824fn sanitize_id_suffix(raw: &str) -> String {
825    raw.chars()
826        .filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
827        .collect()
828}
829
830/// Generate an SVG badge string from [`BadgeParams`].
831///
832/// # Arguments
833/// * `params` - Badge parameters (see [`BadgeParams`]).
834///
835/// # Returns
836/// SVG string representing the badge.
837///
838/// ## Example
839/// ```rust
840/// use shields::{BadgeParams, BadgeStyle, render_badge_svg};
841/// let params = BadgeParams {
842///     style: BadgeStyle::Flat,
843///     label: Some("build"),
844///     message: Some("passing"),
845///     label_color: Some("green"),
846///     message_color: Some("brightgreen"),
847///     link: Some("https://ci.example.com"),
848///     extra_link: None,
849///     logo: None,
850///     logo_color: None,
851/// };
852/// let svg = render_badge_svg(&params);
853/// assert!(svg.contains("passing"));
854/// ```
855pub fn render_badge_svg(params: &BadgeParams) -> String {
856    render_badge_svg_with(params, &RenderOptions::default())
857}
858
859/// Generate an SVG badge string from [`BadgeParams`] plus [`RenderOptions`].
860///
861/// ## Example
862/// ```rust
863/// use shields::{BadgeParams, BadgeStyle, RenderOptions, render_badge_svg_with};
864/// let params = BadgeParams {
865///     style: BadgeStyle::Flat,
866///     label: Some("build"),
867///     message: Some("passing"),
868///     label_color: None,
869///     message_color: None,
870///     link: None,
871///     extra_link: None,
872///     logo: None,
873///     logo_color: None,
874/// };
875/// let svg = render_badge_svg_with(&params, &RenderOptions::default().id_suffix("b1"));
876/// assert!(svg.contains(r##"id="sb1""##));
877/// ```
878pub 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
883/// Generate an SVG badge string, returning an error instead of an HTML
884/// comment when template rendering fails.
885///
886/// [`render_badge_svg`] silently embeds failures as `<!-- Askama render
887/// error -->` comments; use this variant when the caller needs to react.
888pub fn try_render_badge_svg(params: &BadgeParams) -> Result<String, RenderError> {
889    try_render_badge_svg_with(params, &RenderOptions::default())
890}
891
892/// [`try_render_badge_svg`] with additional [`RenderOptions`].
893pub 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/// Error returned by [`try_render_badge_svg`] when template rendering fails.
901#[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
912/// Renders `ctx`, reserving room for the logo up front.
913///
914/// `Template::render` sizes its buffer from `SIZE_HINT`, which only covers the template's
915/// literal text. A badge carrying a base64 logo runs several KB past that and would
916/// reallocate mid-render.
917fn 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
923/// Resolves `logo` (a Simple Icons slug or a raw `<svg>` string) to the `href` value
924/// the templates embed: a base64 data URI, or an empty string when nothing resolves.
925fn 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        // Without the simple-icons feature, named logos resolve to nothing
934        #[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    // Only inject fill when the <svg> tag does not already carry one
944    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
959/// Resolving a logo means a Simple Icons lookup, a fill rewrite and a base64 encode of
960/// a few KB — roughly 70% of a logo badge's render time, and fully determined by
961/// `(logo, logo_color)`. A small per-thread cache keeps it off the hot path; being
962/// thread-local, it costs no lock and does not serialize concurrent rendering.
963mod logo_cache {
964    use std::cell::RefCell;
965    use std::rc::Rc;
966
967    const CAPACITY: usize = 16;
968
969    thread_local! {
970        /// Least-recently-used first, so eviction pops the front.
971        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    // A rejected link is treated exactly like an absent one, so layout stays consistent.
1055    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            // for-the-badge is styled in all caps; convert before measuring widths
1257            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            // Upstream truncates the font measurement (`anafanafo(...) | 0`) and adds
1266            // letter spacing per UTF-16 code unit, keeping fractional widths throughout.
1267            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            // Upstream checks the caller-supplied labelColor, not the resolved
1282            // default that the shared preprocessing may have filled in.
1283            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            // Logo positioning
1292            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            // Handle label and message rectangles
1299            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); // +2 for ": "
1365    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        // Test SVG rendering
1384        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(&params);
1396        assert!(!svg.is_empty(), "SVG rendering failed");
1397    }
1398
1399    #[test]
1400    fn text_for_the_badge() {
1401        // Test ForTheBadge style rendering
1402        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(&params);
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(&params);
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(&params);
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(&params);
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(&params);
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(&params);
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        // 解析名称
1545        let c = Color::from_str("red").unwrap();
1546        println!("{c:?}");
1547
1548        // 解析HEX
1549        let c = Color::from_str("#ff0080").unwrap();
1550        println!("{c:?}");
1551
1552        // 解析RGBA
1553        let c = Color::from_str("rgba(255,255,0,0.75)").unwrap();
1554        println!("{c:?}");
1555
1556        // 解析HSL
1557        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        // Unsafe characters are stripped, and the default is suffix-free
1606        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(&params);
1649        let wide_svg = render_badge_svg_with(&params, &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        // totalLogoWidth = logoWidth + logoPadding, so +16px logo -> +16px badge
1659        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(&params);
1683        // Test that the badge contains expected text
1684        assert!(svg.contains("custom"), "Badge should contain 'custom' text");
1685        assert!(svg.contains("logo"), "Badge should contain 'logo' text");
1686
1687        // Test that SVG contains custom logo (base64 encoded)
1688        assert!(
1689            svg.contains("data:image/svg+xml;base64,"),
1690            "SVG should contain base64 encoded custom logo"
1691        );
1692
1693        // Test that the logo color is applied to the custom SVG (in lowercase)
1694        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        // `&`, `<` and `"` are ordinary badge text ("AT&T", "C++ <3"); rendering
1729        // them raw produced SVG that no XML parser would accept.
1730        for style in ALL_STYLES {
1731            // Social and for-the-badge recase the label, so match on the
1732            // case-insensitive form; what matters is that `&` and `<` are entities.
1733            let svg = render(style, "AT&T <3", "a\"b'c", None).to_lowercase();
1734            assert!(svg.contains("at&amp;t &lt;3"), "{style:?}: {svg}");
1735            assert!(svg.contains("a&quot;b&apos;c"), "{style:?}: {svg}");
1736            // No raw special character survives into the markup.
1737            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        // Ordinary links keep working, including relative ones with a colon.
1774        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&amp;b=2\""), "{svg}");
1785    }
1786
1787    #[test]
1788    fn test_escaping_preserves_logo_and_font() {
1789        // The logo data URI is base64 (no escapable characters) and the font
1790        // stack has none either; escaping must leave both byte-identical.
1791        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        // Nothing was escaped at all: no entity appears anywhere in the output.
1804        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        // Only the social style capitalizes, and only the first character; the
1825        // rest reaching the badge lowercased would also narrow it.
1826        let svg = render(BadgeStyle::Social, "AT&T", "PASSING", None);
1827        assert!(svg.contains("AT&amp;T"), "{svg}");
1828        assert!(svg.contains("PASSING"), "{svg}");
1829    }
1830}