Skip to main content

bmp2webp/
bmp2webp.rs

1use std::fs;
2use std::io::{Error as IoError, ErrorKind};
3use std::path::{Path, PathBuf};
4
5use webp_rust::encoder::{
6    encode_lossless_image_to_webp_with_options, encode_lossy_image_to_webp_with_options,
7};
8use webp_rust::{ImageBuffer, LosslessEncodingOptions, LossyEncodingOptions};
9
10type Error = Box<dyn std::error::Error>;
11
12const FILE_HEADER_SIZE: usize = 14;
13const MIN_INFO_HEADER_SIZE: usize = 40;
14
15fn invalid_data(message: &'static str) -> Error {
16    Box::new(IoError::new(ErrorKind::InvalidData, message))
17}
18
19fn invalid_input(message: &'static str) -> Error {
20    Box::new(IoError::new(ErrorKind::InvalidInput, message))
21}
22
23fn invalid_input_owned(message: String) -> Error {
24    Box::new(IoError::new(ErrorKind::InvalidInput, message))
25}
26
27fn read_u16_le(data: &[u8], offset: usize) -> Result<u16, Error> {
28    let bytes = data
29        .get(offset..offset + 2)
30        .ok_or_else(|| invalid_data("BMP header is truncated"))?;
31    Ok(u16::from_le_bytes([bytes[0], bytes[1]]))
32}
33
34fn read_u32_le(data: &[u8], offset: usize) -> Result<u32, Error> {
35    let bytes = data
36        .get(offset..offset + 4)
37        .ok_or_else(|| invalid_data("BMP header is truncated"))?;
38    Ok(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
39}
40
41fn read_i32_le(data: &[u8], offset: usize) -> Result<i32, Error> {
42    let bytes = data
43        .get(offset..offset + 4)
44        .ok_or_else(|| invalid_data("BMP header is truncated"))?;
45    Ok(i32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
46}
47
48fn row_stride(width: usize, bytes_per_pixel: usize) -> Result<usize, Error> {
49    let raw = width
50        .checked_mul(bytes_per_pixel)
51        .ok_or_else(|| invalid_data("BMP row size overflow"))?;
52    Ok((raw + 3) & !3)
53}
54
55fn decode_bmp_to_rgba(data: &[u8]) -> Result<ImageBuffer, Error> {
56    if data.len() < FILE_HEADER_SIZE + MIN_INFO_HEADER_SIZE {
57        return Err(invalid_data("BMP file is too small"));
58    }
59    if &data[0..2] != b"BM" {
60        return Err(invalid_data("expected a BMP file"));
61    }
62
63    let pixel_offset = read_u32_le(data, 10)? as usize;
64    let dib_header_size = read_u32_le(data, 14)? as usize;
65    if dib_header_size < MIN_INFO_HEADER_SIZE {
66        return Err(invalid_data("unsupported BMP DIB header"));
67    }
68    if data.len() < FILE_HEADER_SIZE + dib_header_size {
69        return Err(invalid_data("BMP DIB header is truncated"));
70    }
71
72    let width_i32 = read_i32_le(data, 18)?;
73    let height_i32 = read_i32_le(data, 22)?;
74    let planes = read_u16_le(data, 26)?;
75    let bits_per_pixel = read_u16_le(data, 28)?;
76    let compression = read_u32_le(data, 30)?;
77
78    if planes != 1 {
79        return Err(invalid_data("unsupported BMP plane count"));
80    }
81    if compression != 0 {
82        return Err(invalid_data("only uncompressed BMP is supported"));
83    }
84    if width_i32 <= 0 {
85        return Err(invalid_data("BMP width must be positive"));
86    }
87    if height_i32 == 0 {
88        return Err(invalid_data("BMP height must be non-zero"));
89    }
90
91    let bytes_per_pixel = match bits_per_pixel {
92        24 => 3usize,
93        32 => 4usize,
94        _ => return Err(invalid_data("only 24bpp and 32bpp BMP are supported")),
95    };
96
97    let width = width_i32 as usize;
98    let top_down = height_i32 < 0;
99    let height = height_i32
100        .checked_abs()
101        .ok_or_else(|| invalid_data("BMP height is out of range"))? as usize;
102
103    let stride = row_stride(width, bytes_per_pixel)?;
104    let pixel_bytes = stride
105        .checked_mul(height)
106        .ok_or_else(|| invalid_data("BMP pixel storage overflow"))?;
107    let pixel_end = pixel_offset
108        .checked_add(pixel_bytes)
109        .ok_or_else(|| invalid_data("BMP pixel offset overflow"))?;
110    if pixel_offset > data.len() || pixel_end > data.len() {
111        return Err(invalid_data("BMP pixel data is truncated"));
112    }
113
114    let rgba_len = width
115        .checked_mul(height)
116        .and_then(|pixels| pixels.checked_mul(4))
117        .ok_or_else(|| invalid_data("BMP output size overflow"))?;
118    let mut rgba = vec![0u8; rgba_len];
119
120    for y in 0..height {
121        let src_y = if top_down { y } else { height - 1 - y };
122        let src_row = pixel_offset + src_y * stride;
123        let dst_row = y * width * 4;
124        for x in 0..width {
125            let src = src_row + x * bytes_per_pixel;
126            let dst = dst_row + x * 4;
127            rgba[dst] = data[src + 2];
128            rgba[dst + 1] = data[src + 1];
129            rgba[dst + 2] = data[src];
130            rgba[dst + 3] = if bytes_per_pixel == 4 {
131                data[src + 3]
132            } else {
133                0xff
134            };
135        }
136    }
137
138    Ok(ImageBuffer {
139        width,
140        height,
141        rgba,
142    })
143}
144
145fn default_output_path(input: &Path) -> PathBuf {
146    let mut output = input.to_path_buf();
147    output.set_extension("webp");
148    output
149}
150
151fn usage() -> &'static str {
152    "usage: cargo run --example bmp2webp -- [-z 0..9] [--lossy --quality 0..100 [--lossy-opt-level 0..9]] [--opt-level 0..9] <input.bmp> [output.webp]"
153}
154
155fn parse_u8_level(value: &str, what: &str) -> Result<u8, Error> {
156    value
157        .parse::<u8>()
158        .map_err(|_| invalid_input_owned(format!("invalid {what}: {value}")))
159}
160
161fn parse_optimization_level(value: &str) -> Result<u8, Error> {
162    let level = parse_u8_level(value, "optimization level")?;
163    if level > 9 {
164        return Err(invalid_input("optimization level must be in 0..=9"));
165    }
166    Ok(level)
167}
168
169fn parse_lossy_optimization_level(value: &str) -> Result<u8, Error> {
170    let level = parse_u8_level(value, "lossy optimization level")?;
171    if level > 9 {
172        return Err(invalid_input("lossy optimization level must be in 0..=9"));
173    }
174    Ok(level)
175}
176
177fn parse_quality(value: &str) -> Result<u8, Error> {
178    let quality = value
179        .parse::<u8>()
180        .map_err(|_| invalid_input_owned(format!("invalid quality: {value}")))?;
181    if quality > 100 {
182        return Err(invalid_input("quality must be in 0..=100"));
183    }
184    Ok(quality)
185}
186
187fn main() -> Result<(), Error> {
188    let mut args = std::env::args_os().skip(1);
189    let mut input = None;
190    let mut output = None;
191    let mut options = LosslessEncodingOptions::default();
192    let mut lossy = false;
193    let mut lossy_options = LossyEncodingOptions::default();
194    let mut shared_optimization_level = None;
195    let mut lossless_optimization_explicit = false;
196    let mut lossy_optimization_explicit = false;
197
198    while let Some(arg) = args.next() {
199        match arg.to_string_lossy().as_ref() {
200            "--lossy" => lossy = true,
201            "--opt" | "--opt-level" | "-O" => {
202                let value = args.next().ok_or_else(|| invalid_input(usage()))?;
203                options.optimization_level = parse_optimization_level(&value.to_string_lossy())?;
204                lossless_optimization_explicit = true;
205            }
206            "--quality" | "-q" => {
207                let value = args.next().ok_or_else(|| invalid_input(usage()))?;
208                lossy_options.quality = parse_quality(&value.to_string_lossy())?;
209            }
210            "-z" => {
211                let value = args.next().ok_or_else(|| invalid_input(usage()))?;
212                shared_optimization_level = Some(parse_u8_level(
213                    &value.to_string_lossy(),
214                    "optimization level",
215                )?);
216            }
217            "--lossy-opt-level" => {
218                let value = args.next().ok_or_else(|| invalid_input(usage()))?;
219                lossy_options.optimization_level =
220                    parse_lossy_optimization_level(&value.to_string_lossy())?;
221                lossy_optimization_explicit = true;
222            }
223            _ => {
224                if input.is_none() {
225                    input = Some(PathBuf::from(arg));
226                } else if output.is_none() {
227                    output = Some(PathBuf::from(arg));
228                } else {
229                    return Err(invalid_input(usage()));
230                }
231            }
232        }
233    }
234
235    if let Some(level) = shared_optimization_level {
236        if lossy {
237            if !lossy_optimization_explicit {
238                lossy_options.optimization_level = if level <= 9 {
239                    level
240                } else {
241                    return Err(invalid_input("lossy optimization level must be in 0..=9"));
242                };
243            }
244        } else if !lossless_optimization_explicit {
245            options.optimization_level = if level <= 9 {
246                level
247            } else {
248                return Err(invalid_input("optimization level must be in 0..=9"));
249            };
250        }
251    }
252
253    let input = input.ok_or_else(|| invalid_input(usage()))?;
254    let output = output.unwrap_or_else(|| default_output_path(&input));
255
256    let data = fs::read(&input)?;
257    let image = decode_bmp_to_rgba(&data)?;
258    let webp = if lossy {
259        encode_lossy_image_to_webp_with_options(&image, &lossy_options)?
260    } else {
261        encode_lossless_image_to_webp_with_options(&image, &options)?
262    };
263
264    if let Some(parent) = output.parent() {
265        if !parent.as_os_str().is_empty() {
266            fs::create_dir_all(parent)?;
267        }
268    }
269    fs::write(&output, webp)?;
270
271    println!("{}", output.display());
272    Ok(())
273}