shield_maker/
badge.rs

1use std::fmt::Display;
2use std::ops::Rem;
3use ab_glyph::{Font, FontArc, Glyph, point, PxScale, ScaleFont};
4use crate::{color, xml};
5use crate::xml::Pusher;
6use crate::plastic_style::Plastic;
7use crate::flat_style::Flat;
8use crate::flat_square_style::FlatSquare;
9
10fn measure_line(font: FontArc, text: &str, scale: PxScale) -> (f32, f32) {
11    let font = font.as_scaled(scale);
12
13    let mut caret = point(0.0, font.ascent());
14    let mut first_glyph: Option<Glyph> = None;
15    let mut last_glyph: Option<Glyph> = None;
16    for c in text.chars().filter(|c| !c.is_control()) {
17        let mut glyph = font.scaled_glyph(c);
18        if let Some(prev) = last_glyph.take() {
19            caret.x += font.kern(prev.id, glyph.id);
20        }
21        glyph.position = caret;
22
23        if first_glyph.is_none() {
24            first_glyph = Some(glyph.clone());
25        }
26        last_glyph = Some(glyph.clone());
27        caret.x += font.h_advance(glyph.id);
28    }
29
30    let height = font.ascent() - font.descent() + font.line_gap();
31    let width = {
32        let min_x = first_glyph.unwrap().position.x;
33        let last_glyph = last_glyph.unwrap();
34        let max_x = last_glyph.position.x + font.h_advance(last_glyph.id);
35        (max_x - min_x).ceil()
36    };
37
38    (width, height)
39}
40
41const FONT_FAMILY: &str = "Verdana,Geneva,DejaVu Sans,sans-serif";
42const FONT_SCALE_UP_FACTOR: f32 = 10.0;
43const FONT_SCALE_DOWN_VALUE: &str = "scale(.1)";
44
45const WIDTH_FONT_SCALE: f32 = 11.0;
46
47/// Represents the desired style of a badge
48#[derive(Copy, Clone)]
49pub enum Style {
50    /// Plastic generates a rounded, plastic-ish looking badge. This is the
51    /// default style generated by shields.io, for instance.
52    Plastic,
53
54    /// Flat is just like Plastic, without gradients.
55    Flat,
56
57    /// FlatSquare contains no rounded corners nor gradients.
58    FlatSquare,
59}
60
61/// Represents the desired font family of a badge
62pub enum FontFamily {
63    /// Uses a font family provided by this crate, comprised of Verdana, Geneva
64    /// DejaVu Sans, and sans-serif.
65    Default,
66
67    /// Uses a provided string as the font family for rendering the badge.
68    Custom(String),
69}
70
71impl FontFamily {
72    fn string(&self) -> String {
73        match self {
74            FontFamily::Default => FONT_FAMILY.into(),
75            FontFamily::Custom(val) => val.clone(),
76        }
77    }
78}
79
80/// Metadata represents all information required to build a badge.
81pub struct Metadata<'a> {
82    /// The desired badge style
83    pub style: Style,
84
85    /// The text to be shown on the badge's label (left side)
86    pub label: &'a str,
87
88    /// The message to be shown on the badge's message (right side)
89    pub message: &'a str,
90
91    /// A [FontArc](ab_glyph::FontArc) to be used for measuring the final size
92    /// of a badge.
93    pub font: FontArc,
94
95    /// The [FontFamily](shield_maker::FontFamily) to be used when rendering this
96    /// badge.
97    pub font_family: FontFamily,
98
99    /// The color for the badge's label background. When `None`, a default
100    /// grayish tone is used. When provided, any CSS color may be used.
101    pub label_color: Option<&'a str>,
102
103    /// The color for the badge's message background. When `None`, a default
104    /// greenish color is used. When provided, any CSS color may be used.
105    pub color: Option<&'a str>,
106}
107
108pub(crate) struct GradientStop<'a> {
109    pub(crate) offset: &'a str,
110    pub(crate) stop_color: &'a str,
111    pub(crate) stop_opacity: &'a str,
112}
113
114impl GradientStop<'_> {
115    pub(crate) fn into_attributes(self, of: &mut xml::Node) {
116        of.add_attrs(&[
117            ("offset", self.offset),
118            ("stop-color", self.stop_color),
119            ("stop-opacity", self.stop_opacity),
120        ]);
121    }
122}
123
124fn round_up_to_odd(val: f32) -> f32 {
125    if val.rem(2.0) as i32 == 0 {
126        val + 1.0
127    } else {
128        val
129    }.round()
130}
131
132fn preferred_width_of(text: &str, font: FontArc, scale: PxScale) -> f32 {
133    let (w, _) = measure_line(font, text, scale);
134    let val = round_up_to_odd(w);
135    val * 1.0345
136}
137
138fn colors_for_background(color_str: &str) -> Option<(&str, &str)> {
139    const BRIGHTNESS_THRESHOLD: f32 = 0.69;
140    let parsed_color = match color::color_by_name(Some(color_str)) {
141        Some(c) => c,
142        None => return None,
143    };
144
145    if color::brightness(parsed_color) <= BRIGHTNESS_THRESHOLD {
146        return Some(("#fff", "#010101"));
147    }
148
149    Some(("#333", "#ccc"))
150}
151
152pub(crate) trait Badger {
153    fn vertical_margin(&self) -> f32;
154    fn height(&self) -> f32;
155    fn shadow(&self) -> bool;
156    fn render(&self, parent: &Renderer) -> Vec<xml::Node>;
157}
158
159/// Renderer implements all mechanisms required to turn a provided badge
160/// [Metadata](Metadata) into its SVG representation.
161pub struct Renderer<'a> {
162    horizontal_padding: f32,
163
164    label_margin: f32,
165    message_margin: f32,
166    label_width: f32,
167    message_width: f32,
168
169    left_width: f32,
170    right_width: f32,
171    font_family: String,
172
173    width: f32,
174    label_color: css_color_parser::Color,
175    color: css_color_parser::Color,
176    label: &'a str,
177    message: &'a str,
178    accessible_text: String,
179
180    style: Box<dyn Badger>,
181}
182
183impl Renderer<'_> {
184    fn new<'a>(info: &'a Metadata<'a>) -> Renderer<'a> {
185        let horizontal_padding = 5.0;
186
187        let label_margin = 1.0;
188        let scale = PxScale::from(WIDTH_FONT_SCALE);
189        let label_width = preferred_width_of(info.label, info.font.clone(), scale);
190        let left_width = label_width + 2.0 * horizontal_padding;
191
192        let message_width = preferred_width_of(info.message, info.font.clone(), scale);
193        let message_margin = left_width - 1.0;
194        let right_width = message_width + 2.0 * horizontal_padding;
195        let width = left_width + right_width;
196        let label_color = color::color_by_name(info.label_color).unwrap_or_else(|| color::color_by_name(Some("#555")).unwrap());
197        let color = color::color_by_name(info.color).unwrap_or_else(|| color::color_by_name(Some("#4c1")).unwrap());
198
199        let accessible_text = format!("{}: {}", info.label, info.message);
200
201        let styler: Box<dyn Badger> = match info.style {
202            Style::Plastic => Box::new(Plastic {}),
203            Style::Flat => Box::new(Flat {}),
204            Style::FlatSquare => Box::new(FlatSquare {}),
205        };
206
207        Renderer {
208            horizontal_padding,
209            label_margin,
210            message_margin,
211            label_width,
212            message_width,
213            left_width,
214            right_width,
215            font_family: info.font_family.string(),
216            width,
217            label_color,
218            color,
219            label: info.label,
220            message: info.message,
221            accessible_text,
222            style: styler,
223        }
224    }
225
226    /// Render renders a given set of [Metadata] into its SVG representation.
227    pub fn render(info: &Metadata) -> String {
228        let mut render = Renderer::new(info);
229        render.internal_render()
230    }
231
232    fn internal_render(&mut self) -> String {
233        let title = xml::Node::with_name_and("title",
234                                             |n| n.push_text(&self.accessible_text));
235
236        let mut svg = xml::Node::with_attributes("svg", &[
237            ("xmlns", "http://www.w3.org/2000/svg"),
238            ("xmlns:xlink", "http://www.w3.org/1999/xlink"),
239            ("width", &format!("{}", self.width)),
240            ("height", &format!("{}", self.style.height())),
241            ("role", "img"),
242            ("aria-label", &self.accessible_text),
243        ]);
244        svg.push_node(title);
245        svg.push_nodes(self.style.render(self));
246
247        let mut doc = xml::Document::new();
248        doc.push_node(svg);
249        xml::Renderer::render(&doc)
250    }
251
252    fn make_text_element(&self, left_margin: f32, content: &str, color: &str, text_width: f32) -> Vec<xml::Node> {
253        let (text_color, shadow_color) = colors_for_background(color).unwrap_or(("", ""));
254
255        let x = FONT_SCALE_UP_FACTOR * (left_margin + 0.5 * text_width + self.horizontal_padding);
256        let mut result = vec![];
257
258        if self.style.shadow() {
259            let shadow = xml::Node::with_name_and("text", |n| {
260                n.add_attrs(&[
261                    ("aria-hidden", "true"),
262                    ("fill", shadow_color),
263                    ("x", &format!("{}", x)),
264                    ("y", &format!("{}", 150.0 + self.style.vertical_margin())),
265                    ("fill-opacity", ".3"),
266                    ("transform", FONT_SCALE_DOWN_VALUE),
267                    ("textLength", &format!("{}", FONT_SCALE_UP_FACTOR * text_width)),
268                ]);
269                n.push_text(content);
270            });
271            result.push(shadow);
272        }
273
274        result.push(xml::Node::with_name_and("text", |n| {
275            n.add_attrs(&[
276                ("fill", text_color),
277                ("x", &format!("{}", x)),
278                ("y", &format!("{}", 140.0 + self.style.vertical_margin())),
279                ("transform", FONT_SCALE_DOWN_VALUE),
280                ("textLength", &format!("{}", FONT_SCALE_UP_FACTOR * text_width)),
281            ]);
282            n.push_text(content);
283        }));
284
285        result
286    }
287
288    fn make_label_element(&self) -> Vec<xml::Node> {
289        self.make_text_element(self.label_margin, self.label, &color::color_to_string(self.label_color), self.label_width)
290    }
291
292    fn make_message_element(&self) -> Vec<xml::Node> {
293        self.make_text_element(self.message_margin, self.message, &color::color_to_string(self.color), self.message_width)
294    }
295
296    pub(crate) fn make_clip_path_element(&self, radius: f32) -> xml::Node {
297        xml::Node::with_name_and("clipPath", |n| {
298            n.add_attr("id", "r");
299            n.push_node_named("rect", |n| {
300                n.add_attrs(&[
301                    ("fill", "#fff"),
302                    ("width", &format!("{}", self.width)),
303                    ("height", &format!("{}", self.style.height())),
304                    ("rx", &format!("{}", radius)),
305                ]);
306            });
307        })
308    }
309
310    pub(crate) fn make_background_group_element<V: Display + ?Sized>(&self, with_gradient: bool, attributes: &[(&str, &V)]) -> xml::Node {
311        xml::Node::with_name_and("g", |n| {
312            n.add_attrs(attributes);
313            let height = format!("{}", self.style.height());
314            let left_width = format!("{}", self.left_width);
315
316            // left rect
317            n.push_node_named("rect", |r| {
318                r.add_attrs(&[
319                    ("width", &left_width),
320                    ("height", &height),
321                    ("fill", &color::color_to_string(self.label_color)),
322                ]);
323            });
324
325            // right rect
326            n.push_node_named("rect", |r| {
327                r.add_attrs(&[
328                    ("x", &left_width),
329                    ("width", &format!("{}", self.right_width)),
330                    ("height", &height),
331                    ("fill", &color::color_to_string(self.color)),
332                ]);
333            });
334
335            if with_gradient {
336                n.push_node_named("rect", |r| {
337                    r.add_attrs(&[
338                        ("fill", "url(#s)"),
339                        ("width", &format!("{}", self.width)),
340                        ("height", &height),
341                    ]);
342                })
343            }
344        })
345    }
346
347    pub(crate) fn make_foreground_group_element(&self) -> xml::Node {
348        xml::Node::with_name_and("g", |n| {
349            n.push_nodes(self.make_label_element());
350            n.push_nodes(self.make_message_element());
351            n.add_attrs(&[
352                ("fill", "#fff"),
353                ("text-anchor", "middle"),
354                ("font-family", &self.font_family),
355                ("text-rendering", "geometricPrecision"),
356                ("font-size", "110"),
357            ]);
358        })
359    }
360}