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('&', "&")
165 .replace('<', "<")
166 .replace('>', ">")
167 .replace('"', """)
168 .replace('\'', "'")
169}