1use std::fs::File;
2use std::io::{copy, BufReader};
3
4use image::imageops::overlay;
5use image::{DynamicImage, ImageBuffer, ImageReader};
6use log::{info, warn};
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9
10#[derive(Clone, Debug, Default, PartialEq, clap::ValueEnum, Serialize)]
11pub enum ForegroundColor {
13 #[default]
14 Light,
15 Dark,
16}
17
18#[derive(Debug)]
19pub struct ScreenDimensions {
21 pub width: u32,
22 pub height: u32,
23}
24
25#[derive(Error, Debug)]
26pub enum XkcdError {
27 #[error("Network error: {0}")]
28 Network(#[from] ureq::Error),
29 #[error("Image error: {0}")]
30 Image(#[from] image::ImageError),
31 #[error("IO error: {0}")]
32 Io(#[from] std::io::Error),
33 #[error("Tempfile error: {0}")]
34 Tempfile(#[from] tempfile::PersistError),
35 #[error("Other error: {0}")]
36 Other(String),
37}
38
39#[derive(Clone, Debug, Deserialize)]
40pub struct Metadata {
42 pub num: u64,
43 pub safe_title: String,
44 pub img: String,
45 pub day: String,
46 pub month: String,
47 pub year: String,
48}
49
50impl Metadata {
51 pub fn from_comic_id(comic_number: Option<u32>) -> Result<Metadata, XkcdError> {
52 let metadata_url = match comic_number {
53 Some(num) => format!("https://xkcd.com/{}/info.0.json", num),
54 None => "https://xkcd.com/info.0.json".to_string(),
55 };
56 info!("downloading metadata from url {}", metadata_url);
57
58 let recv_body = ureq::get(metadata_url)
59 .call()?
60 .body_mut()
61 .read_json::<Metadata>()?;
62 info!("metadata downloaded successfully");
63
64 Ok(recv_body)
65 }
66
67 pub fn to_image(&self) -> Result<ComicImage, XkcdError> {
68 let mut file = tempfile::NamedTempFile::with_suffix(".png")?;
70 download_img(&self.img, file.as_file_mut())?;
71
72 let img = ImageReader::open(file.path())?.decode()?;
73
74 Ok(ComicImage {
75 img,
76 metadata: self.clone(),
77 })
78 }
79}
80
81#[derive(Debug)]
82pub struct Image {
84 pub img: DynamicImage,
85 pub metadata: Metadata,
86}
87type ComicImage = Image;
88type WallpaperImage = Image;
89
90impl Image {
91 pub fn save(&self, filename: &str) {
103 let filename = convert_fmt_filename(filename, &self.metadata);
104 let _ = self.img.save(filename);
105 }
106}
107
108impl ComicImage {
109 pub fn from_metadata(metadata: Metadata) -> Result<Self, XkcdError> {
110 let mut file = tempfile::NamedTempFile::with_suffix(".png")?;
112 download_img(&metadata.img, file.as_file_mut())?;
113
114 let img = ImageReader::open(file.path())?.decode()?;
115
116 Ok(WallpaperImage { img, metadata })
117 }
118}
119
120pub fn get_wallpaper_from_comic(
122 comic_img: ComicImage,
123 fg_color: ForegroundColor,
124 bg_color: image::Rgba<u8>,
125 screen_dimensions: ScreenDimensions,
126) -> WallpaperImage {
127 let metadata = comic_img.metadata;
128 let mut comic_img = comic_img.img.to_owned();
129
130 if fg_color == ForegroundColor::Light {
131 info!("inverting image colors");
132 comic_img.invert();
133 }
134
135 let mut comic_buffer = comic_img.into_rgba8();
136
137 let comic_background_color = match fg_color {
138 ForegroundColor::Light => image::Rgba([0, 0, 0, 255]),
139 ForegroundColor::Dark => image::Rgba([255, 255, 255, 255]),
140 };
141
142 info!("replacing background pixels with background colors");
143 for (_x, _y, pixel) in comic_buffer.enumerate_pixels_mut() {
144 if *pixel == comic_background_color {
145 *pixel = bg_color;
146 }
147 }
148
149 info!("placing comic in center of the background");
151 let mut background_buffer =
152 ImageBuffer::from_pixel(screen_dimensions.width, screen_dimensions.height, bg_color);
153 overlay(
154 &mut background_buffer,
155 &comic_buffer,
156 (screen_dimensions.width / 2 - comic_buffer.width() / 2).into(),
157 (screen_dimensions.height / 2 - comic_buffer.height() / 2).into(),
158 );
159
160 WallpaperImage {
161 img: DynamicImage::ImageRgba8(background_buffer),
162 metadata,
163 }
164}
165
166fn download_img(original_url: &str, mut output_file: &File) -> Result<(), XkcdError> {
167 let scaled_url = original_url.replace(".png", "_2x.png");
168
169 info!("downloading img {}", scaled_url);
170 let mut response = match ureq::get(scaled_url).call() {
171 Ok(res) => res,
172 Err(_) => {
173 warn!(
174 "cannot get image with 2x resolution, falling back to regular res. {}",
175 original_url
176 );
177 ureq::get(original_url).call()?
178 }
179 };
180
181 info!("reading response into BufReader");
182 let mut reader = BufReader::new(response.body_mut().with_config().reader());
183 copy(&mut reader, &mut output_file)?;
184
185 Ok(())
186}
187
188fn convert_fmt_filename(format_filename: &str, metadata: &Metadata) -> String {
189 let replacements = [
190 ("%y", metadata.year.as_str()),
191 ("%m", metadata.month.as_str()),
192 ("%d", metadata.day.as_str()),
193 ("%t", metadata.safe_title.as_str()),
194 ("%n", &metadata.num.to_string()),
195 ];
196
197 let mut output = format_filename.to_owned();
198 for (token, value) in &replacements {
199 output = output.replace(token, value);
200 }
201
202 info!("converted filename from {} to {}", format_filename, output);
203 output
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209 use rstest::rstest;
210
211 #[rstest]
212 #[case("%y.png", "2025.png")]
213 #[case("output/file.png", "output/file.png")]
214 #[case("%y-%m-%d_%t", "2025-06-27_Some title")]
215 fn convert_filename_ok(#[case] input: &str, #[case] output: &str) {
216 let metadata = Metadata {
217 num: 42,
218 safe_title: "Some title".to_string(),
219 year: "2025".to_string(),
220 month: "06".to_string(),
221 day: "27".to_string(),
222 img: "https://example.com".to_string(),
223 };
224
225 assert_eq!(convert_fmt_filename(input, &metadata), output);
226 }
227}