1use std::fmt::Display;
44use std::io::Cursor;
45use std::path::Path;
46
47use image::imageops;
48use image::{ImageError, ImageFormat};
49
50use rusttype::Font;
51use typed_builder::TypedBuilder;
52
53mod components;
54
55#[derive(TypedBuilder)]
56pub struct QuoteProducer<'font> {
57 #[builder(default = (1920, 1080), setter( transform = |width: u32, height: u32| (width, height) ))]
58 output_size: (u32, u32),
59 #[builder(default = 140.0)]
60 font_scale: f32,
61 #[builder(setter(
62 transform = |bold: &'font [u8], light: &'font [u8]| {
63 let bold = Font::try_from_bytes(bold).unwrap_or_else(|| panic!("invalid bold font data"));
64 let light = Font::try_from_bytes(light).unwrap_or_else(|| panic!("invalid light font data"));
65 FontSet {
66 bold, light
67 }
68 }
69 ))]
70 font: FontSet<'font>,
71}
72
73pub struct FontSet<'font> {
74 bold: Font<'font>,
75 light: Font<'font>,
76}
77
78pub enum SpooledData<'data> {
79 InMem(&'data [u8]),
80 OnDisk(&'data Path),
81 TgRandom { id: u64, name: String },
82}
83
84pub trait AsSpooledData {
85 fn as_spooled_data(&self) -> SpooledData<'_>;
86}
87
88impl<P> AsSpooledData for P
89where
90 P: AsRef<Path>,
91{
92 fn as_spooled_data(&self) -> SpooledData<'_> {
93 SpooledData::OnDisk(self.as_ref())
94 }
95}
96
97impl AsSpooledData for str {
98 fn as_spooled_data(&self) -> SpooledData<'_> {
99 SpooledData::OnDisk(self.as_ref())
100 }
101}
102
103impl AsSpooledData for [u8] {
104 fn as_spooled_data(&self) -> SpooledData<'_> {
105 SpooledData::InMem(self)
106 }
107}
108
109impl<'data> AsSpooledData for SpooledData<'data> {
110 fn as_spooled_data(&self) -> SpooledData<'_> {
111 match self {
112 SpooledData::InMem(m) => SpooledData::InMem(m),
113 SpooledData::OnDisk(d) => SpooledData::OnDisk(d),
114 SpooledData::TgRandom { id, name } => SpooledData::TgRandom {
115 id: *id,
116 name: name.to_string(),
117 },
118 }
119 }
120}
121
122#[derive(TypedBuilder)]
123pub struct ImgConfig<'a> {
124 #[builder(setter( transform = |s: impl Display| s.to_string() ))]
125 quote: String,
126 #[builder(setter( transform = |s: impl Display| s.to_string() ))]
127 username: String,
128 #[builder(setter( transform = |p: &'a (impl AsSpooledData + ?Sized)| p.as_spooled_data() ))]
129 avatar: SpooledData<'a>,
130}
131
132impl<'font> QuoteProducer<'font> {
133 pub fn make_image(&self, config: &ImgConfig) -> Result<Vec<u8>> {
134 let mut background = components::Background::builder()
135 .output_dimension(self.output_size)
136 .build();
137
138 let avatar = match &config.avatar {
140 SpooledData::InMem(buffer) => {
141 let img_data = image::load_from_memory(buffer)?.into_rgba8();
142 components::Avatar::builder()
143 .img_data(img_data)
144 .bg_height(background.height())
145 .build()
146 }
147 SpooledData::OnDisk(path) => {
148 let img_data = image::open(path)?.into_rgba8();
149 components::Avatar::builder()
150 .img_data(img_data)
151 .bg_height(background.height())
152 .build()
153 }
154 SpooledData::TgRandom { id, name } => {
155 let letter = name.chars().next().unwrap().to_string();
156 let info = components::TextDrawInfo::builder()
157 .text(&letter)
158 .rgba([255, 255, 255, 255])
159 .scale(300.0)
160 .font(&self.font.bold)
161 .build();
162 let img_data = components::TgAvatar::builder()
163 .id(*id)
164 .info(info)
165 .bg_dim(background.dimensions())
166 .build();
167 components::Avatar::builder()
168 .img_data(img_data)
169 .bg_height(background.height())
170 .enable_crop(false)
171 .build()
172 }
173 };
174 imageops::overlay(&mut background, &avatar, 0, 0);
175
176 let gradient = components::Transition::builder()
178 .avatar_width(avatar.width())
179 .bg_height(background.height())
180 .build();
181 let offset = (avatar.width() - gradient.width()) as i64;
182 imageops::overlay(&mut background, &gradient, offset, 0);
183
184 let quote_info = components::TextDrawInfo::builder()
186 .text(&config.quote)
187 .rgba([255, 255, 255, 255])
188 .scale(self.font_scale)
189 .font(&self.font.bold)
190 .build();
191 let user_info = components::TextDrawInfo::builder()
192 .text(&config.username)
193 .rgba([147, 147, 147, 255])
194 .scale(self.font_scale / 1.5)
195 .font(&self.font.light)
196 .build();
197 let quotes = components::Quotes::builder()
198 .avatar_width(avatar.width())
199 .bg_dim(background.dimensions())
200 .quote_info(quote_info)
201 .user_info(user_info)
202 .build();
203 let offset = avatar.width() as i64;
204 imageops::overlay(&mut background, "es, offset, 0);
205
206 let mut buffer = Cursor::new(Vec::new());
207 background.write_to(&mut buffer, ImageFormat::Jpeg)?;
208 Ok(buffer.into_inner())
209 }
210}
211
212#[derive(thiserror::Error, Debug)]
213pub enum ErrorKind {
214 #[error("internal image library error: {0}")]
215 ImgErr(#[from] ImageError),
216 #[error("fail to read font: {0}")]
217 FontErr(#[from] std::io::Error),
218}
219
220type Result<T, E = ErrorKind> = core::result::Result<T, E>;
221
222#[test]
223fn test_create_background_image() {
224 use std::time::Instant;
225
226 let bold_font = std::fs::read("/usr/share/fonts/noto-cjk/NotoSansCJK-Medium.ttc").unwrap();
227 let light_font = include_bytes!("/usr/share/fonts/noto-cjk/NotoSansCJK-Light.ttc");
228 let builder = QuoteProducer::builder()
229 .font(&bold_font, light_font)
230 .build();
231
232 let config = ImgConfig::builder()
233 .username("@V5电竞俱乐部中单选手 Otto")
234 .avatar("./assets/avatar.png")
235 .quote("大家好,今天来点大家想看的东西。\nccccccabackajcka 阿米诺说的道理")
236 .build();
237
238 let now = Instant::now();
239 let buffer = builder.make_image(&config).unwrap();
240 std::fs::write("./assets/test.jpg", buffer).unwrap();
241 println!("elapsed: {} ms", now.elapsed().as_millis());
242 let data = SpooledData::TgRandom {
243 id: 13,
244 name: "ksyx".to_string(),
245 };
246 let config = ImgConfig::builder()
247 .username("@ksyxmeow")
248 .avatar(&data)
249 .quote("教授可爱喵喵喵")
250 .build();
251 let buffer = builder.make_image(&config).unwrap();
252 std::fs::write("./assets/test-tg.jpg", buffer).unwrap();
253}