diff --git a/src/images.rs b/src/images.rs index 39e5504..8585622 100644 --- a/src/images.rs +++ b/src/images.rs @@ -1,6 +1,6 @@ use crate::palettes::{Color, Palette}; use anyhow::{Context, Result, bail}; -use image::{Pixel, Rgba, RgbaImage}; +use image::{Pixel, Rgb, Rgba, RgbaImage}; use std::fs::File; use std::io::Write; use std::path::Path; @@ -12,65 +12,30 @@ pub fn convert_image(in_path: &Path, out_path: &Path, sys_pal: &Palette) -> Resu if img.width() % 8 != 0 { bail!("image width must be divisible by 8"); } - let mut img_pal = make_palette(&img, sys_pal).context("detect colors used in the image")?; - let mut out = File::create(out_path).context("create output path")?; - // The magic number. "2"=image, "1"=v1. - write_u8(&mut out, 0x21)?; - let n_colors = img_pal.len(); - if n_colors <= 2 { - if n_colors <= 1 { - println!("⚠️ the image has only one color."); - } - extend_palette(&mut img_pal, sys_pal, 2); - write_image::<1, 8>(out, &img, &img_pal, sys_pal).context("write 1BPP image") - } else if n_colors <= 4 { - extend_palette(&mut img_pal, sys_pal, 4); - write_image::<2, 4>(out, &img, &img_pal, sys_pal).context("write 1BPP image") - } else if n_colors <= 16 { - extend_palette(&mut img_pal, sys_pal, 16); - write_image::<4, 2>(out, &img, &img_pal, sys_pal).context("write 1BPP image") - } else { - let has_transparency = img_pal.iter().any(Option::is_none); - if has_transparency && n_colors == 17 { - bail!("cannot use all 16 colors with transparency, remove one color"); - } - bail!("the image has too many colors"); - } + let transp = find_unused_color(&img, sys_pal).context("detect colors used in the image")?; + let out = File::create(out_path).context("create output path")?; + write_image(out, &img, sys_pal, transp).context("write image") } -fn write_image( - mut out: File, - img: &RgbaImage, - img_pal: &[Color], - sys_pal: &Palette, -) -> Result<()> { - write_u8(&mut out, BPP)?; // BPP +fn write_image(mut out: File, img: &RgbaImage, sys_pal: &Palette, transp: u8) -> Result<()> { + const BPP: u8 = 4; + const PPB: usize = 2; + let Ok(width) = u16::try_from(img.width()) else { bail!("the image is too big") }; + write_u8(&mut out, 0x22)?; // magic number write_u16(&mut out, width)?; // image width - let transparent = pick_transparent(img_pal, sys_pal)?; - write_u8(&mut out, transparent)?; // transparent color + write_u8(&mut out, transp)?; // transparent color - // palette swaps - let mut byte = 0; - debug_assert!(img_pal.len() == 2 || img_pal.len() == 4 || img_pal.len() == 16); - for (i, color) in img_pal.iter().enumerate() { - let index = match color { - Some(color) => find_color(sys_pal, Some(*color)), - None => transparent, - }; - byte = (byte << 4) | index; - if i % 2 == 1 { - write_u8(&mut out, byte)?; - } - } - - // image raw packed bytes + // Pixel values. let mut byte: u8 = 0; for (i, pixel) in img.pixels().enumerate() { let color = convert_color(*pixel); - let raw_color = find_color(img_pal, color); + let raw_color = match color { + Some(color) => find_color(sys_pal, color), + None => transp, + }; byte = (byte << BPP) | raw_color; if (i + 1) % PPB == 0 { write_u8(&mut out, byte)?; @@ -79,77 +44,33 @@ fn write_image( Ok(()) } -/// Detect all colors used in the image. -fn make_palette(img: &RgbaImage, sys_pal: &Palette) -> Result> { - let mut palette = Vec::new(); +/// Find color from the palette not used on the image. +/// +/// Additionally ensures that the image uses the given color palette. +fn find_unused_color(img: &RgbaImage, sys_pal: &Palette) -> Result { + let mut used_colors: Vec = Vec::new(); + let mut has_transp = false; for (x, y, pixel) in img.enumerate_pixels() { - let color = convert_color(*pixel); - if !palette.contains(&color) { - if color.is_some() && !sys_pal.contains(&color) { - bail!( - "found a color not present in the color palette: {} (at x={x}, y={y})", - format_color(color), - ); - } - palette.push(color); + let Some(color) = convert_color(*pixel) else { + has_transp = true; + continue; + }; + if !sys_pal.contains(&color) { + bail!( + "found a color not present in the color palette: {} (at x={x}, y={y})", + format_color(color), + ); } - } - palette.sort_by_key(|c| match c { - Some(c) => find_color(sys_pal, Some(*c)), - None => 20, - }); - Ok(palette) -} - -/// Add empty colors at the end of the palette to match the BPP size. -/// -/// If the given image palette is fully contained within the system palette -/// (after being cut to the expected swaps size), place the colors in the -/// image palette in the same positions as they are in the system palette. -/// This will make it possible to read such images without worrying about -/// applying color swaps. -fn extend_palette(img_pal: &mut Vec, sys_pal: &Palette, size: usize) { - if img_pal.len() > size { - return; - } - - let sys_pal_prefix = &sys_pal[..size]; - if !is_subpalette(img_pal, sys_pal_prefix) { - img_pal.extend_from_slice(&sys_pal[img_pal.len()..size]); - return; - } - - // No transparency? Just use the system palette. - let has_transp = img_pal.iter().any(Option::is_none); - if !has_transp { - img_pal.clear(); - img_pal.extend(sys_pal_prefix); - return; - } - - // Has transparency? Then copy the system palette and poke one hole in it. - let mut new_pal: Vec = Vec::new(); - let mut found_transp = false; - for c in sys_pal_prefix { - if found_transp || img_pal.contains(c) { - new_pal.push(*c); - } else { - new_pal.push(None); - found_transp = true; + if !used_colors.contains(&color) { + used_colors.push(color); } } - img_pal.clear(); - img_pal.extend(new_pal); -} -/// Check if the image palette is fully contained within the given system palette. -fn is_subpalette(img_pal: &[Color], sys_pal: &[Color]) -> bool { - for c in img_pal { - if c.is_some() && !sys_pal.contains(c) { - return false; - } + if has_transp { + pick_transparent(&used_colors, sys_pal) + } else { + Ok(0xff) } - true } fn write_u8(f: &mut File, v: u8) -> std::io::Result<()> { @@ -161,7 +82,7 @@ fn write_u16(f: &mut File, v: u16) -> std::io::Result<()> { } /// Find the index of the given color in the given palette. -fn find_color(palette: &[Color], c: Color) -> u8 { +fn find_color(palette: &Palette, c: Rgb) -> u8 { for (color, i) in palette.iter().zip(0u8..) { if *color == c { return i; @@ -172,45 +93,35 @@ fn find_color(palette: &[Color], c: Color) -> u8 { /// Make human-readable hex representation of the color code. fn format_color(c: Color) -> String { - match c { - Some(c) => { - let c = c.0; - format!("#{:02X}{:02X}{:02X}", c[0], c[1], c[2]) - } - None => "ALPHA".to_string(), - } + let c = c.0; + format!("#{:02X}{:02X}{:02X}", c[0], c[1], c[2]) } -fn convert_color(c: Rgba) -> Color { - if is_transparent(c) { +fn convert_color(c: Rgba) -> Option { + let alpha = c.0[3]; + let is_transparent = alpha < 128; + if is_transparent { return None; } Some(c.to_rgb()) } -const fn is_transparent(c: Rgba) -> bool { - let alpha = c.0[3]; - alpha < 128 -} - /// Pick the color to be used to represent transparency fn pick_transparent(img_pal: &[Color], sys_pal: &Palette) -> Result { - if img_pal.iter().all(Option::is_some) { - // no transparency needed - return Ok(17); - } + assert!(img_pal.len() <= sys_pal.len()); + assert!(sys_pal.len() <= 16); for (color, i) in sys_pal.iter().zip(0u8..) { if !img_pal.contains(color) { return Ok(i); } } - if img_pal.len() > 16 { - bail!("the image cannot contain more than 16 colors") + if sys_pal.len() == 16 { + bail!("cannot use all 16 colors with transparency, remove one color"); } - if img_pal.len() == 16 { - bail!("an image cannot contain all 16 colors and transparency") - } - bail!("image contains colors not from the palette") + // If the system palette has less than 16 colors, + // any of the colors outside the palette + // can be used for transparency. We use 15. + Ok(0xf) } #[cfg(test)] @@ -221,8 +132,7 @@ mod tests { #[test] fn test_format_color() { - assert_eq!(format_color(None), "ALPHA"); - assert_eq!(format_color(Some(Rgb([0x89, 0xab, 0xcd]))), "#89ABCD"); + assert_eq!(format_color(Rgb([0x89, 0xab, 0xcd])), "#89ABCD"); } #[test] @@ -232,50 +142,8 @@ mod tests { let c1 = pal[1]; let c2 = pal[2]; let c3 = pal[3]; - assert_eq!(pick_transparent(&[c0, c1], pal).unwrap(), 17); - assert_eq!(pick_transparent(&[c0, c1, None], pal).unwrap(), 2); - assert_eq!(pick_transparent(&[c0, None, c1], pal).unwrap(), 2); - assert_eq!(pick_transparent(&[c1, c0, None], pal).unwrap(), 2); - assert_eq!(pick_transparent(&[c0, c1, c2, c3, None], pal).unwrap(), 4); - } - - #[test] - fn test_extend_palette() { - let pal = SWEETIE16; - let c0 = pal[0]; - let c1 = pal[1]; - let c2 = pal[2]; - let c3 = pal[3]; - let c4 = pal[4]; - - // Already the palette prefix, do nothing. - let mut img_pal = vec![c0, c1]; - extend_palette(&mut img_pal, pal, 2); - assert_eq!(img_pal, vec![c0, c1]); - - // A prefix but in a wrong order. Fix the order. - let mut img_pal = vec![c1, c0]; - extend_palette(&mut img_pal, pal, 2); - assert_eq!(img_pal, vec![c0, c1]); - - // Not a prefix and already full. Keep the given palette. - let mut img_pal = vec![c2, c1]; - extend_palette(&mut img_pal, pal, 2); - assert_eq!(img_pal, vec![c2, c1]); - - // A prefix but too short. Fill the rest. - let mut img_pal = vec![c0, c1]; - extend_palette(&mut img_pal, pal, 4); - assert_eq!(img_pal, vec![c0, c1, c2, c3]); - - // Within the palette prefix. - let mut img_pal = vec![c2, c1]; - extend_palette(&mut img_pal, pal, 4); - assert_eq!(img_pal, vec![c0, c1, c2, c3]); - - // Not a prefix but too short. Don't touch the given, fill the rest. - let mut img_pal = vec![c4, c2]; - extend_palette(&mut img_pal, pal, 4); - assert_eq!(img_pal, vec![c4, c2, c2, c3]); + assert_eq!(pick_transparent(&[c0, c1], pal).unwrap(), 2); + assert_eq!(pick_transparent(&[c1, c0], pal).unwrap(), 2); + assert_eq!(pick_transparent(&[c0, c1, c2, c3], pal).unwrap(), 4); } } diff --git a/src/palettes.rs b/src/palettes.rs index d2a5f0d..499c283 100644 --- a/src/palettes.rs +++ b/src/palettes.rs @@ -2,9 +2,9 @@ use anyhow::{Context, Result, bail}; use image::Rgb; use std::collections::HashMap; -pub type Color = Option>; -pub type Palette = [Color; 16]; -pub type Palettes = HashMap; +pub type Color = Rgb; +pub type Palette = [Color]; +pub type Palettes = HashMap>; type RawPalette = HashMap; /// The default color palette (SWEETIE-16). @@ -12,110 +12,78 @@ type RawPalette = HashMap; /// /// pub static SWEETIE16: &Palette = &[ - Some(Rgb([0x1a, 0x1c, 0x2c])), // #1a1c2c: black - Some(Rgb([0x5d, 0x27, 0x5d])), // #5d275d: purple - Some(Rgb([0xb1, 0x3e, 0x53])), // #b13e53: red - Some(Rgb([0xef, 0x7d, 0x57])), // #ef7d57: orange - Some(Rgb([0xff, 0xcd, 0x75])), // #ffcd75: yellow - Some(Rgb([0xa7, 0xf0, 0x70])), // #a7f070: light green - Some(Rgb([0x38, 0xb7, 0x64])), // #38b764: green - Some(Rgb([0x25, 0x71, 0x79])), // #257179: dark green - Some(Rgb([0x29, 0x36, 0x6f])), // #29366f: dark blue - Some(Rgb([0x3b, 0x5d, 0xc9])), // #3b5dc9: blue - Some(Rgb([0x41, 0xa6, 0xf6])), // #41a6f6: light blue - Some(Rgb([0x73, 0xef, 0xf7])), // #73eff7: cyan - Some(Rgb([0xf4, 0xf4, 0xf4])), // #f4f4f4: white - Some(Rgb([0x94, 0xb0, 0xc2])), // #94b0c2: light gray - Some(Rgb([0x56, 0x6c, 0x86])), // #566c86: gray - Some(Rgb([0x33, 0x3c, 0x57])), // #333c57: dark gray + Rgb([0x1a, 0x1c, 0x2c]), // #1a1c2c: black + Rgb([0x5d, 0x27, 0x5d]), // #5d275d: purple + Rgb([0xb1, 0x3e, 0x53]), // #b13e53: red + Rgb([0xef, 0x7d, 0x57]), // #ef7d57: orange + Rgb([0xff, 0xcd, 0x75]), // #ffcd75: yellow + Rgb([0xa7, 0xf0, 0x70]), // #a7f070: light green + Rgb([0x38, 0xb7, 0x64]), // #38b764: green + Rgb([0x25, 0x71, 0x79]), // #257179: dark green + Rgb([0x29, 0x36, 0x6f]), // #29366f: dark blue + Rgb([0x3b, 0x5d, 0xc9]), // #3b5dc9: blue + Rgb([0x41, 0xa6, 0xf6]), // #41a6f6: light blue + Rgb([0x73, 0xef, 0xf7]), // #73eff7: cyan + Rgb([0xf4, 0xf4, 0xf4]), // #f4f4f4: white + Rgb([0x94, 0xb0, 0xc2]), // #94b0c2: light gray + Rgb([0x56, 0x6c, 0x86]), // #566c86: gray + Rgb([0x33, 0x3c, 0x57]), // #333c57: dark gray ]; /// The PICO-8 color palette. /// /// static PICO8: &Palette = &[ - Some(Rgb([0x00, 0x00, 0x00])), // #000000: black - Some(Rgb([0x1D, 0x2B, 0x53])), // #1D2B53: dark blue - Some(Rgb([0x7E, 0x25, 0x53])), // #7E2553: dark purple - Some(Rgb([0x00, 0x87, 0x51])), // #008751: dark green - Some(Rgb([0xAB, 0x52, 0x36])), // #AB5236: brown - Some(Rgb([0x5F, 0x57, 0x4F])), // #5F574F: dark gray - Some(Rgb([0xC2, 0xC3, 0xC7])), // #C2C3C7: light gray - Some(Rgb([0xFF, 0xF1, 0xE8])), // #FFF1E8: white - Some(Rgb([0xFF, 0x00, 0x4D])), // #FF004D: red - Some(Rgb([0xFF, 0xA3, 0x00])), // #FFA300: orange - Some(Rgb([0xFF, 0xEC, 0x27])), // #FFEC27: yellow - Some(Rgb([0x00, 0xE4, 0x36])), // #00E436: green - Some(Rgb([0x29, 0xAD, 0xFF])), // #29ADFF: blue - Some(Rgb([0x83, 0x76, 0x9C])), // #83769C: indigo - Some(Rgb([0xFF, 0x77, 0xA8])), // #FF77A8: pink - Some(Rgb([0xFF, 0xCC, 0xAA])), // #FFCCAA: peach + Rgb([0x00, 0x00, 0x00]), // #000000: black + Rgb([0x1D, 0x2B, 0x53]), // #1D2B53: dark blue + Rgb([0x7E, 0x25, 0x53]), // #7E2553: dark purple + Rgb([0x00, 0x87, 0x51]), // #008751: dark green + Rgb([0xAB, 0x52, 0x36]), // #AB5236: brown + Rgb([0x5F, 0x57, 0x4F]), // #5F574F: dark gray + Rgb([0xC2, 0xC3, 0xC7]), // #C2C3C7: light gray + Rgb([0xFF, 0xF1, 0xE8]), // #FFF1E8: white + Rgb([0xFF, 0x00, 0x4D]), // #FF004D: red + Rgb([0xFF, 0xA3, 0x00]), // #FFA300: orange + Rgb([0xFF, 0xEC, 0x27]), // #FFEC27: yellow + Rgb([0x00, 0xE4, 0x36]), // #00E436: green + Rgb([0x29, 0xAD, 0xFF]), // #29ADFF: blue + Rgb([0x83, 0x76, 0x9C]), // #83769C: indigo + Rgb([0xFF, 0x77, 0xA8]), // #FF77A8: pink + Rgb([0xFF, 0xCC, 0xAA]), // #FFCCAA: peach ]; /// SLSO8 color palette. /// /// static SLSO8: &Palette = &[ - Some(Rgb([0x0d, 0x2b, 0x45])), // #0d2b45 - Some(Rgb([0x20, 0x3c, 0x56])), // #203c56 - Some(Rgb([0x54, 0x4e, 0x68])), // #544e68 - Some(Rgb([0x8d, 0x69, 0x7a])), // #8d697a - Some(Rgb([0xd0, 0x81, 0x59])), // #d08159 - Some(Rgb([0xff, 0xaa, 0x5e])), // #ffaa5e - Some(Rgb([0xff, 0xd4, 0xa3])), // #ffd4a3 - Some(Rgb([0xff, 0xec, 0xd6])), // #ffecd6 - None, - None, - None, - None, - None, - None, - None, - None, + Rgb([0x0d, 0x2b, 0x45]), // #0d2b45 + Rgb([0x20, 0x3c, 0x56]), // #203c56 + Rgb([0x54, 0x4e, 0x68]), // #544e68 + Rgb([0x8d, 0x69, 0x7a]), // #8d697a + Rgb([0xd0, 0x81, 0x59]), // #d08159 + Rgb([0xff, 0xaa, 0x5e]), // #ffaa5e + Rgb([0xff, 0xd4, 0xa3]), // #ffd4a3 + Rgb([0xff, 0xec, 0xd6]), // #ffecd6 ]; /// The Kirokaze Gameboy color palette. /// /// static GAMEBOY: &Palette = &[ - Some(Rgb([0x33, 0x2c, 0x50])), // #332c50: purple - Some(Rgb([0x46, 0x87, 0x8f])), // #46878f: blue - Some(Rgb([0x94, 0xe3, 0x44])), // #94e344: green - Some(Rgb([0xe2, 0xf3, 0xe4])), // #e2f3e4: white - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, + Rgb([0x33, 0x2c, 0x50]), // #332c50: purple + Rgb([0x46, 0x87, 0x8f]), // #46878f: blue + Rgb([0x94, 0xe3, 0x44]), // #94e344: green + Rgb([0xe2, 0xf3, 0xe4]), // #e2f3e4: white ]; /// WASM-4 color palette. /// /// static WASM4: &Palette = &[ - Some(Rgb([0xE0, 0xF8, 0xCF])), // #E0F8CF: white - Some(Rgb([0x86, 0xC0, 0x6C])), // #86C06C: light green - Some(Rgb([0x30, 0x68, 0x50])), // #306850: dark green - Some(Rgb([0x07, 0x18, 0x21])), // #071821: black - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, + Rgb([0xE0, 0xF8, 0xCF]), // #E0F8CF: white + Rgb([0x86, 0xC0, 0x6C]), // #86C06C: light green + Rgb([0x30, 0x68, 0x50]), // #306850: dark green + Rgb([0x07, 0x18, 0x21]), // #071821: black ]; pub fn parse_palettes(raws: Option<&HashMap>) -> Result { @@ -130,7 +98,7 @@ pub fn parse_palettes(raws: Option<&HashMap>) -> Result Result { +fn parse_palette(raw: &RawPalette) -> Result> { let len = raw.len(); if len > 16 { bail!("too many colors") @@ -143,14 +111,13 @@ fn parse_palette(raw: &RawPalette) -> Result { } let len = u16::try_from(len).unwrap(); - let mut palette: Palette = Palette::default(); + let mut palette = Vec::new(); for id in 1u16..=len { let Some(raw_color) = raw.get(&id.to_string()) else { bail!("color IDs must be consecutive but ID {id} is missing"); }; let color = parse_color(*raw_color)?; - let idx = usize::from(id - 1); - palette[idx] = color; + palette.push(color); } Ok(palette) } @@ -163,7 +130,7 @@ fn parse_color(raw: u32) -> Result { let r = (raw >> 16) as u8; let g = (raw >> 8) as u8; let b = raw as u8; - Ok(Some(Rgb([r, g, b]))) + Ok(Rgb([r, g, b])) } pub fn get_palette<'a>(name: Option<&str>, palettes: &'a Palettes) -> Result<&'a Palette> { @@ -203,23 +170,10 @@ mod tests { ps.insert("rgb".to_string(), p); let res = parse_palettes(Some(&ps)).unwrap(); assert_eq!(res.len(), 1); - let exp: Palette = [ - Some(Rgb([0xff, 0x00, 0x00])), - Some(Rgb([0x00, 0xff, 0x00])), - Some(Rgb([0x00, 0x00, 0xff])), - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, + let exp: &Palette = &[ + Rgb([0xff, 0x00, 0x00]), + Rgb([0x00, 0xff, 0x00]), + Rgb([0x00, 0x00, 0xff]), ]; assert_eq!(*res.get("rgb").unwrap(), exp); } @@ -227,7 +181,7 @@ mod tests { #[test] fn test_get_palette() { let mut p = Palettes::new(); - p.insert("sup".to_string(), *SWEETIE16); + p.insert("sup".to_string(), Vec::from(SWEETIE16)); assert_eq!(get_palette(None, &p).unwrap(), SWEETIE16); assert_eq!(get_palette(Some("sup"), &p).unwrap(), SWEETIE16); assert_eq!(get_palette(Some("sweetie16"), &p).unwrap(), SWEETIE16);