Skip to main content

fluxer_poll/
lib.rs

1use anyhow::{Result, bail};
2use resvg::{tiny_skia, usvg};
3
4const CARD_W: u32 = 800;
5const PADDING: f32 = 48.0;
6const HEADER_H: f32 = 148.0;
7const OPTION_H: f32 = 88.0;
8const FOOTER_H: f32 = 48.0;
9const BAR_H: f32 = 10.0;
10const BAR_MAX_W: f32 = CARD_W as f32 - PADDING * 2.0;
11pub const MAX_OPTIONS: usize = 7;
12
13const SVG_TEMPLATE: &str = include_str!("../template.svg");
14
15const COLOR_PCT: &str = "#6ab3f3";
16const COLOR_LABEL_WINNER: &str = "#ffffff";
17const COLOR_LABEL_NORMAL: &str = "rgba(255,255,255,0.90)";
18const COLOR_BAR_WINNER: &str = "#6ab3f3";
19const COLOR_BAR_NORMAL: &str = "#3390ec";
20
21pub struct PollOption {
22    pub label: String,
23    pub votes: u32,
24}
25
26pub struct PollCard {
27    pub title: String,
28    pub options: Vec<PollOption>,
29    pub header_label: String,
30    pub votes_label: String,
31}
32
33impl PollCard {
34    pub fn new(title: impl Into<String>) -> Self {
35        Self {
36            title: title.into(),
37            options: Vec::new(),
38            header_label: "POLL".into(),
39            votes_label: "votes".into(),
40        }
41    }
42
43    pub fn option(mut self, label: impl Into<String>, votes: u32) -> Self {
44        self.options.push(PollOption {
45            label: label.into(),
46            votes,
47        });
48        self
49    }
50
51    pub fn header_label(mut self, label: impl Into<String>) -> Self {
52        self.header_label = label.into();
53        self
54    }
55
56    pub fn votes_label(mut self, label: impl Into<String>) -> Self {
57        self.votes_label = label.into();
58        self
59    }
60
61    pub fn render_png(&self) -> Result<Vec<u8>> {
62        let svg = build_svg(self)?;
63        let opt = usvg::Options::default();
64        let mut db = usvg::fontdb::Database::new();
65        db.load_system_fonts();
66        db.load_font_data(include_bytes!("fonts/SegoeUI.ttf").to_vec());
67        db.load_font_data(include_bytes!("fonts/SegoeUI-SemiBold.ttf").to_vec());
68        db.load_font_data(include_bytes!("fonts/SegoeUI-Bold.ttf").to_vec());
69
70        let tree = usvg::Tree::from_str(&svg, &opt, &db)?;
71        let w = tree.size().width() as u32;
72        let h = tree.size().height() as u32;
73
74        let mut pixmap =
75            tiny_skia::Pixmap::new(w, h).ok_or_else(|| anyhow::anyhow!("pixmap alloc failed"))?;
76
77        resvg::render(
78            &tree,
79            tiny_skia::Transform::identity(),
80            &mut pixmap.as_mut(),
81        );
82        Ok(pixmap.encode_png()?)
83    }
84}
85
86fn build_option(i: usize, opt: &PollOption, total: u32, max_votes: u32) -> String {
87    let pct = if total == 0 {
88        0.0_f32
89    } else {
90        opt.votes as f32 / total as f32
91    };
92    let pct_int = (pct * 100.0).round() as u32;
93    let winner = opt.votes == max_votes && max_votes > 0;
94
95    let label_fill = if winner {
96        COLOR_LABEL_WINNER
97    } else {
98        COLOR_LABEL_NORMAL
99    };
100    let bar_fill = if winner {
101        COLOR_BAR_WINNER
102    } else {
103        COLOR_BAR_NORMAL
104    };
105
106    let slot_top = HEADER_H + i as f32 * OPTION_H;
107    let label_y = slot_top + 30.0;
108    let bar_y = slot_top + 52.0;
109    let bar_r = BAR_H / 2.0;
110    let bar_w = (BAR_MAX_W * pct).max(if pct > 0.0 { BAR_H } else { 0.0 });
111
112    let bar_rect = if bar_w > 0.0 {
113        format!(
114            r#"<rect x="{PADDING}" y="{bar_y}" width="{bar_w}" height="{BAR_H}" rx="{bar_r}" fill="{bar_fill}"/>"#,
115        )
116    } else {
117        String::new()
118    };
119
120    format!(
121        r#"<text x="{}" y="{}" font-family="Segoe UI, Inter, sans-serif" font-size="15" font-weight="700" fill="{COLOR_PCT}">{pct_int}%</text>"#,
122        PADDING + 6.0,
123        label_y,
124    ) + &format!(
125        r#"<text x="{}" y="{}" font-family="Segoe UI, Inter, sans-serif" font-size="17" font-weight="500" fill="{label_fill}">{}</text>"#,
126        PADDING + 58.0,
127        label_y,
128        xml_escape(&opt.label),
129    ) + &format!(
130        r#"<rect x="{PADDING}" y="{bar_y}" width="{BAR_MAX_W}" height="{BAR_H}" rx="{bar_r}" fill="rgba(255,255,255,0.06)"/>"#,
131    ) + &bar_rect
132}
133
134fn build_svg(poll: &PollCard) -> Result<String> {
135    if poll.options.is_empty() || poll.options.len() > MAX_OPTIONS {
136        bail!("options count must be 1–{}", MAX_OPTIONS);
137    }
138
139    let total = poll.options.iter().map(|o| o.votes).sum::<u32>();
140    let max_votes = poll.options.iter().map(|o| o.votes).max().unwrap_or(0);
141    let card_h = (HEADER_H + poll.options.len() as f32 * OPTION_H + FOOTER_H) as u32;
142    let footer_y = card_h as f32 - 24.0;
143
144    let options_svg: String = poll
145        .options
146        .iter()
147        .enumerate()
148        .map(|(i, opt)| build_option(i, opt, total, max_votes))
149        .collect();
150
151    Ok(SVG_TEMPLATE
152        .replace("__W__", &CARD_W.to_string())
153        .replace("__H__", &card_h.to_string())
154        .replace("__LABEL__", &xml_escape(&poll.header_label))
155        .replace("__TITLE__", &xml_escape(&poll.title))
156        .replace("__SEP_X__", &(CARD_W as f32 - PADDING).to_string())
157        .replace("__OPTIONS__", &options_svg)
158        .replace("__FOOTER_Y__", &footer_y.to_string())
159        .replace("__TOTAL_VOTES__", &total.to_string())
160        .replace("__VOTES_LABEL__", &xml_escape(&poll.votes_label)))
161}
162
163fn xml_escape(s: &str) -> String {
164    s.replace('&', "&amp;")
165        .replace('<', "&lt;")
166        .replace('>', "&gt;")
167        .replace('"', "&quot;")
168        .replace('\'', "&apos;")
169}