make_quote/
lib.rs

1//! This library provide a single function [`make_quote_image`] to turn somebody's quote into an
2//! image.
3//!
4//! This is not an feature rich library. You may meet some draw issue. Feel free to open issue
5//! at GitHub to help me improve this library. Currently the best practice is to set the output
6//! size to 1920x1080.
7//!
8//! # Usage
9//!
10//! ```rust
11//! use make_quote::{QuoteProducer, ImgConfig};
12//!
13//! // First of all, load an font into memory
14//! let font = std::fs::read("/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc").unwrap();
15//!
16//! // Create a image producer
17//! let bold_font = std::fs::read("/usr/share/fonts/noto-cjk/NotoSansCJK-Bold.ttc").unwrap();
18//! let light_font = include_bytes!("/usr/share/fonts/noto-cjk/NotoSansCJK-Light.ttc");
19//! let producer = QuoteProducer::builder()
20//!     .font(&bold_font, light_font)
21//!     .output_size(1920, 1080) // optional
22//!     .font_scale(120.0)       // optional
23//!     .build();
24//!
25//! // Create image configuration
26//! let config = ImgConfig::builder()
27//!     .username("V5电竞俱乐部中单选手 Otto")
28//!     .avatar("./assets/avatar.png")
29//!     .quote("大家好,今天来点大家想看的东西。")
30//!     .build();
31//!
32//! // Then generate the image and get the image buffer
33//! let buffer = producer.make_image(&config).unwrap();
34//!
35//! // You can do anything you like to the buffer, save it or just send it through the net.
36//! std::fs::write("./assets/test.jpg", buffer).unwrap();
37//! ```
38//!
39//! This will generate the below output:
40//!
41//! <img src="https://github.com/Avimitin/make-quote/raw/master/assets/test.jpg"/>
42
43use 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        // Step 1: Overlay avatar to background
139        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        // Step 2: Overlay black gradient to avatar
177        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        // Step 3: Overlay quotes to background
185        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, &quotes, 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}