Skip to main content

xkcd_wallpaper/
lib.rs

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)]
11/// Foreground color for drawings, either light or dark
12pub enum ForegroundColor {
13    #[default]
14    Light,
15    Dark,
16}
17
18#[derive(Debug)]
19/// Represents dimensions of a screen
20pub 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)]
40/// Metadata obtained through the xkcd API
41pub 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        // NamedTempFile over tempfile because it requires .png suffix to be supported by ImageReader
69        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)]
82/// Wrapper for xkcd image which contains metadata
83pub struct Image {
84    pub img: DynamicImage,
85    pub metadata: Metadata,
86}
87type ComicImage = Image;
88type WallpaperImage = Image;
89
90impl Image {
91    /// Save `Image` to a specific output file, supports placeholders.
92    ///
93    /// # Filename placeholders
94    /// The output filename can use placeholders which will be substituted with corresponding metadata
95    ///
96    /// y   Two-digit year (e.g., 25)
97    /// m   Two-digit month (e.g., 06)
98    /// d   Two-digit day (e.g., 22)
99    /// n   Comic number
100    /// t   Title   
101    /// For instance `./output/%y-%m-%d-%t` would generated a file `./output/2025-06-20-SomeTitle`.
102    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        // NamedTempFile over tempfile because it requires .png suffix to be supported by ImageReader
111        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
120/// Use a comic `Image` to obtain a wallpaper, returned as a `Image`.
121pub 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    // Place comic in the middle of the background buffer
150    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}