From da0f27f006203d1a3184821d220eadb8a1ff83e2 Mon Sep 17 00:00:00 2001 From: M Stoeckl Date: Sat, 12 Jul 2025 20:00:58 -0400 Subject: [PATCH 1/4] Add example convert program --- examples/convert.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 examples/convert.rs diff --git a/examples/convert.rs b/examples/convert.rs new file mode 100644 index 0000000..a5840cb --- /dev/null +++ b/examples/convert.rs @@ -0,0 +1,28 @@ +//! An example of opening an image. +extern crate image; +extern crate image_extras; + +use std::env; +use std::error::Error; +use std::path::Path; + +fn main() -> Result<(), Box> { + image_extras::register(); + + let (from, into) = if env::args_os().count() == 3 { + ( + env::args_os().nth(1).unwrap(), + env::args_os().nth(2).unwrap(), + ) + } else { + println!("Please enter a from and into path."); + std::process::exit(1); + }; + + // Use the open function to load an image from a Path. + // ```open``` returns a dynamic image. + let im = image::open(Path::new(&from)).unwrap(); + // Write the contents of this image using extension guessing. + im.save(Path::new(&into)).unwrap(); + Ok(()) +} From 7c06875194ca64d920044b08a8cde7a9cb733df7 Mon Sep 17 00:00:00 2001 From: M Stoeckl Date: Sat, 12 Jul 2025 12:36:12 -0400 Subject: [PATCH 2/4] Set up an (empty) fuzzing framework --- fuzz/Cargo.toml | 27 +++++++++++++++++++++++++++ fuzz/README.md | 12 ++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 fuzz/Cargo.toml create mode 100644 fuzz/README.md diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..d40b9fc --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,27 @@ + +[package] +name = "image-fuzz" +version = "0.0.1" +authors = ["Automatically generated"] +edition = "2021" +publish = false + +[package.metadata] +cargo-fuzz = true + +[dependencies] +image = { version = "0.25.5", default-features = false } + +[dependencies.image-extras] +path = ".." +features = [] +[dependencies.libfuzzer-sys] +version = "0.4" + +# Temporarily needed for image-extras to build, see ../Cargo.toml +[patch.crates-io] +image = { git = "https://github.com/fintelia/image", branch = "decoding-hooks" } + +# Prevent this from interfering with workspaces +[workspace] +members = ["."] diff --git a/fuzz/README.md b/fuzz/README.md new file mode 100644 index 0000000..9015410 --- /dev/null +++ b/fuzz/README.md @@ -0,0 +1,12 @@ +# Fuzzing with libfuzzer + +For the possibly more up-to-date guide see . + +> $ cargo install cargo-fuzz +> $ cargo +nightly fuzz run fuzzer_script_ + +# Bug reports + +As explained in the project [README](../README.md), fuzzing is not a priority for +this crate and decoders may panic or worse on malformed input. Please do not +open issues for crashes found by fuzzing, though PRs fixing them are welcome. From e7da413676a3401d312a86cafd0feffcb6e1b830 Mon Sep 17 00:00:00 2001 From: M Stoeckl Date: Tue, 4 Feb 2025 21:17:19 -0500 Subject: [PATCH 3/4] Add XBM decoder --- Cargo.toml | 3 +- README.md | 1 + fuzz/Cargo.toml | 6 +- fuzz/README.md | 4 + fuzz/dictionaries/xbm.dict | 16 + fuzz/fuzzers/fuzzer_script_xbm.rs | 24 ++ src/lib.rs | 13 + src/xbm.rs | 676 ++++++++++++++++++++++++++++++ tests/images/xbm/1x1.png | Bin 0 -> 70 bytes tests/images/xbm/1x1.xbm | 3 + tests/images/xbm/hotspot.png | Bin 0 -> 98 bytes tests/images/xbm/hotspot.xbm | 6 + tests/images/xbm/spiral.png | Bin 0 -> 189 bytes tests/images/xbm/spiral.xbm | 7 + 14 files changed, 757 insertions(+), 2 deletions(-) create mode 100644 fuzz/dictionaries/xbm.dict create mode 100644 fuzz/fuzzers/fuzzer_script_xbm.rs create mode 100644 src/xbm.rs create mode 100644 tests/images/xbm/1x1.png create mode 100644 tests/images/xbm/1x1.xbm create mode 100644 tests/images/xbm/hotspot.png create mode 100644 tests/images/xbm/hotspot.xbm create mode 100644 tests/images/xbm/spiral.png create mode 100644 tests/images/xbm/spiral.xbm diff --git a/Cargo.toml b/Cargo.toml index 378cc50..c1fe595 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,8 +8,9 @@ publish = false include = ["src", "tests/reference.rs"] [features] -default = ["pcx"] +default = ["pcx", "xbm"] pcx = ["dep:pcx"] +xbm = [] [dependencies] image = { version = "0.25.5", default-features = false } diff --git a/README.md b/README.md index ffd6f56..02a7752 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ Decoding support for additional image formats beyond those provided by the [`ima | Extension | File Format Description | | --------- | -------------------- | | PCX | [Wikipedia](https://en.wikipedia.org/wiki/PCX#PCX_file_format) | +| XBM | [Wikipedia](https://en.wikipedia.org/wiki/X_BitMap) | ## New Formats diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index d40b9fc..58c76e6 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -14,7 +14,7 @@ image = { version = "0.25.5", default-features = false } [dependencies.image-extras] path = ".." -features = [] +features = ["xbm"] [dependencies.libfuzzer-sys] version = "0.4" @@ -25,3 +25,7 @@ image = { git = "https://github.com/fintelia/image", branch = "decoding-hooks" } # Prevent this from interfering with workspaces [workspace] members = ["."] + +[[bin]] +name = "fuzzer_script_xbm" +path = "fuzzers/fuzzer_script_xbm.rs" diff --git a/fuzz/README.md b/fuzz/README.md index 9015410..444bea3 100644 --- a/fuzz/README.md +++ b/fuzz/README.md @@ -5,6 +5,10 @@ For the possibly more up-to-date guide see $ cargo install cargo-fuzz > $ cargo +nightly fuzz run fuzzer_script_ +Fuzzing may progress faster for certain formats if seeded with a dictionary: + +> $ cargo +nightly fuzz run fuzzer_script_xbm -- -dict=fuzz/dictionaries/xbm.dict + # Bug reports As explained in the project [README](../README.md), fuzzing is not a priority for diff --git a/fuzz/dictionaries/xbm.dict b/fuzz/dictionaries/xbm.dict new file mode 100644 index 0000000..8f6b659 --- /dev/null +++ b/fuzz/dictionaries/xbm.dict @@ -0,0 +1,16 @@ +"#define" +"_width" +"_height" +"_x_hot" +"_y_hot" +"static" +"unsigned" +"char" +"_bits[]" +"=" +"{" +"0x" +"0X" +"," +"}" +";" diff --git a/fuzz/fuzzers/fuzzer_script_xbm.rs b/fuzz/fuzzers/fuzzer_script_xbm.rs new file mode 100644 index 0000000..7794430 --- /dev/null +++ b/fuzz/fuzzers/fuzzer_script_xbm.rs @@ -0,0 +1,24 @@ +#![no_main] +#[macro_use] +extern crate libfuzzer_sys; +extern crate image_extras; +extern crate image; + +use std::io::BufReader; +use image::ImageDecoder; + +fuzz_target!(|data: &[u8]| { + let reader = BufReader::new(data); + let Ok(mut decoder) = image_extras::xbm::XbmDecoder::new(reader) else { + return; + }; + let mut limits = image::Limits::default(); + limits.max_alloc = Some(1024 * 1024); // 1 MiB + if limits.reserve(decoder.total_bytes()).is_err() { + return; + } + if decoder.set_limits(limits).is_err() { + return; + } + let _ = std::hint::black_box(image::DynamicImage::from_decoder(decoder)); +}); diff --git a/src/lib.rs b/src/lib.rs index eadcb93..78a1fc9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,6 +18,9 @@ #[cfg(feature = "pcx")] pub mod pcx; +#[cfg(feature = "xbm")] +pub mod xbm; + /// Register all enabled extra formats with the image crate. pub fn register() { image::hooks::register_decoding_hook( @@ -25,4 +28,14 @@ pub fn register() { Box::new(|r| Ok(Box::new(pcx::PCXDecoder::new(r)?))), ); image::hooks::register_format_detection_hook("pcx".into(), &[0x0a, 0x0], Some(b"\xFF\xF8")); + + // XBM images are often valid C code and have no simple and reliably distinguishing file signature + image::hooks::register_decoding_hook( + "xbm".into(), + Box::new(|r| Ok(Box::new(xbm::XbmDecoder::new(r)?))), + ); + image::hooks::register_decoding_hook( + "bm".into(), + Box::new(|r| Ok(Box::new(xbm::XbmDecoder::new(r)?))), + ); } diff --git a/src/xbm.rs b/src/xbm.rs new file mode 100644 index 0000000..142d772 --- /dev/null +++ b/src/xbm.rs @@ -0,0 +1,676 @@ +//! Decoding of X BitMap (.xbm) Images +//! +//! XBM (X BitMap) Format is a plain text image format, sometimes used to store +//! cursor and icon data. XBM images can be valid C code (although a noticeable +//! fraction of historical images includes a name field which is not a valid C +//! identifier, and need not even be either of pure-ASCII or UTF-8). +//! +//! # Related Links +//! * - The XBM format specification +//! * - The XBM format on wikipedia +#![forbid(unsafe_code)] + +use std::fmt; +use std::io::{BufRead, Bytes}; + +use image::error::{DecodingError, ImageFormatHint, ParameterError, ParameterErrorKind}; +use image::{ColorType, ExtendedColorType, ImageDecoder, ImageError, ImageResult}; + +/// Location of a byte in the input stream. +/// +/// Includes byte offset (for format debugging with hex editor) and +/// line:column offset (for format debugging with text editor) +#[derive(Clone, Copy, Debug)] +struct TextLocation { + byte: u64, + line: u64, + column: u64, +} + +/// A peekable reader which tracks location information +struct TextReader> { + inner: R, + + current: Option, + + location: TextLocation, +} + +impl TextReader +where + R: Iterator, +{ + /// Initialize a TextReader + fn new(mut r: R) -> TextReader { + let current = r.next(); + TextReader { + inner: r, + current, + location: TextLocation { + byte: 0, + line: 1, + column: 0, + }, + } + } + + /// Consume the next byte. On EOF, will return None + fn next(&mut self) -> Option { + self.current?; + + let mut current = self.inner.next(); + std::mem::swap(&mut self.current, &mut current); + + self.location.byte += 1; + self.location.column += 1; + if let Some(b'\n') = current { + self.location.line += 1; + self.location.column = 0; + } + current + } + /// Peek at the next byte. On EOF, will return None + fn peek(&self) -> Option { + self.current + } + /// The location of the last byte returned by [Self::next] + fn loc(&self) -> TextLocation { + self.location + } +} + +/// Properties of an XBM image (excluding the rarely useful `name` field.) +struct XbmHeaderData { + width: u32, + height: u32, + hotspot: Option<(i32, i32)>, +} + +/// XBM stream decoder (works in no_std, has the natural streaming API for the uncompressed text structure of XBM) +/// +/// To properly validate the image trailer, invoke `next_byte()` again after reading the last byte of content; if +/// the trailer is valid it should return Ok(None). +struct XbmStreamDecoder> { + r: TextReader, + current_position: u64, + // Note: technically this includes header metadata that isn't _needed_ when parsing + header: XbmHeaderData, +} + +/// Helper struct to project BufRead down to Iterator. Costs of this simple +/// lifetime-free abstraction include that the struct requires space to store the +/// error value, and that code using this must eventually check the error field. +struct IoAdapter { + reader: Bytes, + error: Option, +} + +impl Iterator for IoAdapter +where + R: BufRead, +{ + type Item = u8; + #[inline(always)] + fn next(&mut self) -> Option { + if self.error.is_some() { + return None; + } + match self.reader.next() { + None => None, + Some(Ok(v)) => Some(v), + Some(Err(e)) => { + self.error = Some(e); + None + } + } + } +} + +/// XBM decoder (usable wrapper of XbmStreamDecoder that handles IO errors) +pub struct XbmDecoder { + base: XbmStreamDecoder>, +} + +/// Part of the XBM file in which a parse error occurs +#[derive(Debug, Clone, Copy)] +enum XbmPart { + Width, + Height, + HotspotX, + HotspotY, + Array, + Data, + ArrayEnd, + Trailing, +} + +/// Error that can occur while parsing an XBM file +#[derive(Debug)] +enum XbmDecodeError { + Parse(XbmPart, TextLocation), + DecodeInteger(XbmPart), + ZeroWidth, + ZeroHeight, +} + +impl fmt::Display for TextLocation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_fmt(format_args!( + "byte={},line={}:col={}", + self.byte, self.line, self.column + )) + } +} + +impl fmt::Display for XbmPart { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + XbmPart::Width => f.write_str("#define for image width"), + XbmPart::Height => f.write_str("#define for image height"), + XbmPart::HotspotX => f.write_str("#define for hotspot x coordinate"), + XbmPart::HotspotY => f.write_str("#define for hotspot y coordinate"), + XbmPart::Array => f.write_str("array definition"), + XbmPart::Data => f.write_str("array content"), + XbmPart::ArrayEnd => f.write_str("array end"), + XbmPart::Trailing => f.write_str("end of file"), + } + } +} + +impl fmt::Display for XbmDecodeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + XbmDecodeError::Parse(part, loc) => f.write_fmt(format_args!( + "Failed to parse {}, unexpected character or eof at {}", + part, loc + )), + XbmDecodeError::DecodeInteger(part) => { + f.write_fmt(format_args!("Failed to parse integer for {}", part)) + } + XbmDecodeError::ZeroWidth => f.write_str("Invalid image width: should not be zero"), + XbmDecodeError::ZeroHeight => f.write_str("Invalid image height: should not be zero"), + } + } +} + +impl std::error::Error for XbmDecodeError {} + +impl From for ImageError { + fn from(e: XbmDecodeError) -> ImageError { + ImageError::Decoding(DecodingError::new(ImageFormatHint::Name("XBM".into()), e)) + } +} + +/// Helper trait for the pattern in which, after calling a function returning a Result, +/// one wishes to use an error from a different source. +trait XbmDecoderIoInjectionExt { + type Value; + fn apply_after(self, err: &mut Option) -> Result; +} + +impl XbmDecoderIoInjectionExt for Result { + type Value = X; + fn apply_after(self, err: &mut Option) -> Result { + if let Some(err) = err.take() { + return Err(ImageError::IoError(err)); + } + match self { + Self::Ok(x) => Ok(x), + Self::Err(e) => Err(ImageError::Decoding(DecodingError::new( + ImageFormatHint::Name("XBM".into()), + e, + ))), + } + } +} + +/// A limit on the length of a #define symbol (containing `name` + '_width') in an image. +/// Names are typically valid C identifiers, and a 255 char limit is common, +/// so XBM files exceeding this are unlikely to work anyway. +const MAX_IDENTIFIER_LENGTH: usize = 256; + +/// Read precisely the string `s` from `r`, or error. +fn read_fixed_string>( + r: &mut TextReader, + s: &[u8], + part: XbmPart, +) -> Result<(), XbmDecodeError> { + for c in s { + if let Some(b) = r.next() { + if b != *c { + return Err(XbmDecodeError::Parse(part, r.loc())); + } + } else { + return Err(XbmDecodeError::Parse(part, r.loc())); + }; + } + Ok(()) +} +// Read a single byte +fn read_byte>( + r: &mut TextReader, + part: XbmPart, +) -> Result { + match r.next() { + None => Err(XbmDecodeError::Parse(part, r.loc())), + Some(b) => Ok(b), + } +} + +/// Read a mixture of ' ' and '\t'. At least one character must be read. +// Other whitespace characters are not permitted. +fn read_whitespace_gap>( + r: &mut TextReader, + part: XbmPart, +) -> Result<(), XbmDecodeError> { + let b = read_byte(r, part)?; + if !(b == b' ' || b == b'\t') { + return Err(XbmDecodeError::Parse(part, r.loc())); + } + while let Some(b) = r.peek() { + if b == b' ' || b == b'\t' { + r.next(); + continue; + } else { + return Ok(()); + } + } + Ok(()) +} +/// Read a mixture of ' ', '\t', and '\n'. Other whitespace characters are not permitted. +fn read_optional_whitespace>( + r: &mut TextReader, +) -> Result<(), XbmDecodeError> { + while let Some(b) = r.peek() { + if b == b' ' || b == b'\t' || b == b'\n' { + r.next(); + continue; + } else { + break; + } + } + Ok(()) +} +/// Read a mixture of ' ' and '\t', until reading '\n'. +fn read_to_newline>( + r: &mut TextReader, + part: XbmPart, +) -> Result<(), XbmDecodeError> { + while let Some(b) = r.peek() { + if b == b' ' || b == b'\t' { + r.next(); + continue; + } else { + break; + } + } + if read_byte(r, part)? != b'\n' { + Err(XbmDecodeError::Parse(part, r.loc())) + } else { + Ok(()) + } +} +/// Read token into the buffer until the buffer size is exceeded, or ' ' or '\t' or '\n' is found +/// Returns the length of the data read. +fn read_until_whitespace<'a, R: Iterator>( + r: &mut TextReader, + buf: &'a mut [u8], + part: XbmPart, +) -> Result<&'a [u8], XbmDecodeError> { + let mut len = 0; + while let Some(b) = r.peek() { + if b == b' ' || b == b'\t' || b == b'\n' { + return Ok(&buf[..len]); + } else { + if len >= buf.len() { + // identifier is too long + return Err(XbmDecodeError::Parse(part, r.loc())); + } + buf[len] = b; + len += 1; + r.next(); + } + } + Ok(&buf[..len]) +} + +/// Read a single hex digit, either upper or lower case +fn read_hex_digit>( + r: &mut TextReader, + part: XbmPart, +) -> Result { + let b = read_byte(r, part)?; + match b { + b'0'..=b'9' => Ok(b - b'0'), + b'A'..=b'F' => Ok(b - b'A' + 10), + b'a'..=b'f' => Ok(b - b'a' + 10), + _ => Err(XbmDecodeError::Parse(part, r.loc())), + } +} + +/// Read a hex-encoded byte (e.g.: 0xA1) +fn read_hex_byte>( + r: &mut TextReader, + part: XbmPart, +) -> Result { + if read_byte(r, part)? != b'0' { + return Err(XbmDecodeError::Parse(part, r.loc())); + } + let x = read_byte(r, part)?; + if !(x == b'x' || x == b'X') { + return Err(XbmDecodeError::Parse(part, r.loc())); + } + let mut v = read_hex_digit(r, part)? << 4; + v += read_hex_digit(r, part)?; + Ok(v) +} + +/// Parse string into signed integer, rejecting leading + and leading zeros +/// (i32::from_str_radix accepts '014' as 14, but in C is it octal and has value 12) +fn parse_i32(data: &[u8]) -> Option { + if data.starts_with(b"-") { + (-(parse_u32(&data[1..])? as i64)).try_into().ok() + } else { + parse_u32(data)?.try_into().ok() + } +} + +/// Parse string into unsigned integer, rejecting leading + and leading zeros +/// (u32::from_str_radix accepts '014' as 14, but in C is it octal and has value 12) +fn parse_u32(data: &[u8]) -> Option { + let Some(c1) = data.first() else { + // Reject empty string + return None; + }; + if *c1 == b'0' && data.len() > 1 { + // Nonzero integers may not have leading zeros + return None; + } + let mut x: u32 = 0; + for c in data { + if b'0' <= *c && *c <= b'9' { + x = x.checked_mul(10)?.checked_add((*c - b'0') as u32)?; + } else { + return None; + } + } + Some(x) +} + +/// Read the XBM file header up to and including the first opening brace +fn read_xbm_header<'a, R: Iterator>( + r: &mut TextReader, + name_width_buf: &'a mut [u8], +) -> Result<(&'a [u8], XbmHeaderData), XbmDecodeError> { + // The header consists of three to five lines. Lines 3-4 may be skipped + // In practice, the name may be empty or UTF-8. + // + // #define _width + // #define _height + // #define _x_hot + // #define _y_hot + // static _bits[] = { ... + let mut int_buf = [0u8; 11]; // -2^31 and 2^32 fit in 11 bytes + + // Read width field and acquire name. + read_fixed_string(r, b"#define", XbmPart::Width)?; + read_whitespace_gap(r, XbmPart::Width)?; + let name_width = read_until_whitespace(r, name_width_buf, XbmPart::Width)?; + if !name_width.ends_with(b"_width") { + return Err(XbmDecodeError::Parse(XbmPart::Width, r.loc())); + } + let name = &name_width[..name_width.len() - b"_width".len()]; + read_whitespace_gap(r, XbmPart::Width)?; + let int = read_until_whitespace(r, &mut int_buf, XbmPart::Width)?; + read_to_newline(r, XbmPart::Width)?; + + let width = parse_u32(int).ok_or(XbmDecodeError::DecodeInteger(XbmPart::Width))?; + if width == 0 { + return Err(XbmDecodeError::ZeroWidth); + } + + // Read height field, checking that the name matches + read_fixed_string(r, b"#define", XbmPart::Height)?; + read_whitespace_gap(r, XbmPart::Height)?; + read_fixed_string(r, name, XbmPart::Height)?; + read_fixed_string(r, b"_height", XbmPart::Height)?; + read_whitespace_gap(r, XbmPart::Height)?; + let int = read_until_whitespace(r, &mut int_buf, XbmPart::Height)?; + read_to_newline(r, XbmPart::Height)?; + + let height = parse_u32(int).ok_or(XbmDecodeError::DecodeInteger(XbmPart::Height))?; + if height == 0 { + return Err(XbmDecodeError::ZeroHeight); + } + + let hotspot = match r.peek() { + Some(b'#') => { + // Parse hotspot lines + read_fixed_string(r, b"#define", XbmPart::HotspotX)?; + read_whitespace_gap(r, XbmPart::HotspotX)?; + read_fixed_string(r, name, XbmPart::HotspotX)?; + read_fixed_string(r, b"_x_hot", XbmPart::HotspotX)?; + read_whitespace_gap(r, XbmPart::HotspotX)?; + let int = read_until_whitespace(r, &mut int_buf, XbmPart::HotspotX)?; + read_to_newline(r, XbmPart::HotspotX)?; + + let hotspot_x = + parse_i32(int).ok_or(XbmDecodeError::DecodeInteger(XbmPart::HotspotX))?; + + read_fixed_string(r, b"#define", XbmPart::HotspotY)?; + read_whitespace_gap(r, XbmPart::HotspotY)?; + read_fixed_string(r, name, XbmPart::HotspotY)?; + read_fixed_string(r, b"_y_hot", XbmPart::HotspotY)?; + read_whitespace_gap(r, XbmPart::HotspotY)?; + let int = read_until_whitespace(r, &mut int_buf, XbmPart::HotspotY)?; + read_to_newline(r, XbmPart::HotspotY)?; + + let hotspot_y = + parse_i32(int).ok_or(XbmDecodeError::DecodeInteger(XbmPart::HotspotY))?; + + Some((hotspot_x, hotspot_y)) + } + Some(b's') => None, + _ => { + r.next(); + return Err(XbmDecodeError::Parse(XbmPart::Array, r.loc())); + } + }; + + read_fixed_string(r, b"static", XbmPart::Array)?; + read_whitespace_gap(r, XbmPart::Array)?; + match r.peek() { + Some(b'c') => { + read_fixed_string(r, b"char", XbmPart::Array)?; + } + Some(b'u') => { + read_fixed_string(r, b"unsigned", XbmPart::Array)?; + read_whitespace_gap(r, XbmPart::Array)?; + read_fixed_string(r, b"char", XbmPart::Array)?; + } + _ => { + r.next(); + return Err(XbmDecodeError::Parse(XbmPart::Array, r.loc())); + } + } + read_whitespace_gap(r, XbmPart::Array)?; + read_fixed_string(r, name, XbmPart::Array)?; + read_fixed_string(r, b"_bits[]", XbmPart::Array)?; + read_whitespace_gap(r, XbmPart::Array)?; + read_fixed_string(r, b"=", XbmPart::Array)?; + read_whitespace_gap(r, XbmPart::Array)?; + read_fixed_string(r, b"{", XbmPart::Array)?; + + Ok(( + name, + XbmHeaderData { + width, + height, + hotspot, + }, + )) +} + +impl XbmStreamDecoder +where + R: Iterator, +{ + /// Create a new `XbmStreamDecoder` or error if the header failed to parse. + pub fn new(reader: R) -> Result, (R, XbmDecodeError)> { + let mut r = TextReader::new(reader); + + let mut name_width_buf = [0u8; MAX_IDENTIFIER_LENGTH]; + match read_xbm_header(&mut r, &mut name_width_buf) { + Err(e) => Err((r.inner, e)), + Ok((_name, header)) => Ok(XbmStreamDecoder { + r, + current_position: 0, + header, + }), + } + } + + /// Read the next byte of the raw image data. The XBM image is organized + /// in row major order with rows containing ceil(width / 8) bytes, so that + /// the `i`th pixel in a row is the `(i%8)`th least significant + /// bit of the `(i/8)`th byte in the row. Bit value 1 = black, 0 = white. + pub fn next_byte(&mut self) -> Result, XbmDecodeError> { + let data_size = (self.header.width.div_ceil(8) as u64) * (self.header.height as u64); + if self.current_position < data_size { + let first = self.current_position == 0; + self.current_position += 1; + + if !first { + read_optional_whitespace(&mut self.r)?; + read_fixed_string(&mut self.r, b",", XbmPart::Data)?; + } + read_optional_whitespace(&mut self.r)?; + Ok(Some(read_hex_byte(&mut self.r, XbmPart::Data)?)) + } else { + // Read optional comma, followed by final }; + read_optional_whitespace(&mut self.r)?; + match self.r.peek() { + Some(b',') => { + read_fixed_string(&mut self.r, b",", XbmPart::Data)?; + read_optional_whitespace(&mut self.r)?; + } + Some(b'}') => (), + _ => { + self.r.next(); + return Err(XbmDecodeError::Parse(XbmPart::ArrayEnd, self.r.loc())); + } + } + read_fixed_string(&mut self.r, b"}", XbmPart::ArrayEnd)?; + read_optional_whitespace(&mut self.r)?; + read_fixed_string(&mut self.r, b";", XbmPart::ArrayEnd)?; + read_optional_whitespace(&mut self.r)?; + + if self.r.next().is_some() { + // File has unexpected trailing contents + return Err(XbmDecodeError::Parse(XbmPart::Trailing, self.r.loc())); + }; + + Ok(None) + } + } +} + +impl XbmDecoder +where + R: BufRead, +{ + /// Create a new `XBMDecoder`. + pub fn new(reader: R) -> Result, ImageError> { + match XbmStreamDecoder::new(IoAdapter { + reader: reader.bytes(), + error: None, + }) { + Err((mut r, e)) => Err(e).apply_after(&mut r.error), + Ok(x) => Ok(XbmDecoder { base: x }), + } + } + + /// Returns the (x,y) hotspot coordinates of the image, if the image provides them. + pub fn hotspot(&self) -> Option<(i32, i32)> { + self.base.header.hotspot + } +} + +impl ImageDecoder for XbmDecoder { + fn dimensions(&self) -> (u32, u32) { + (self.base.header.width, self.base.header.height) + } + fn color_type(&self) -> ColorType { + ColorType::L8 + } + fn original_color_type(&self) -> ExtendedColorType { + ExtendedColorType::L1 + } + fn read_image(mut self, buf: &mut [u8]) -> ImageResult<()> + where + Self: Sized, + { + for row in buf.chunks_exact_mut(self.base.header.width as usize) { + // The XBM format discards the last `8 * ceil(self.width / 8) - self.width` bits in each row + for chunk in row.chunks_mut(8) { + let nxt = self + .base + .next_byte() + .apply_after(&mut self.base.r.inner.error)?; + let val = nxt.ok_or_else(|| { + ImageError::Parameter(ParameterError::from_kind( + ParameterErrorKind::DimensionMismatch, + )) + })?; + for (i, p) in chunk.iter_mut().enumerate() { + // Set bits correspond to black, unset bits to white + *p = if val & (1 << i) == 0 { 0xff } else { 0 }; + } + } + } + + let val = self + .base + .next_byte() + .apply_after(&mut self.base.r.inner.error)?; + if val.is_some() { + return Err(ImageError::Parameter(ParameterError::from_kind( + ParameterErrorKind::DimensionMismatch, + ))); + } + + Ok(()) + } + fn read_image_boxed(self: Box, buf: &mut [u8]) -> ImageResult<()> { + (*self).read_image(buf) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::File; + use std::io::BufReader; + + #[test] + fn image_without_hotspot() { + let decoder = XbmDecoder::new(BufReader::new( + File::open("tests/images/xbm/1x1.xbm").unwrap(), + )) + .expect("Unable to read XBM file"); + + assert_eq!((1, 1), decoder.dimensions()); + assert_eq!(None, decoder.hotspot()); + } + + #[test] + fn image_with_hotspot() { + let decoder = XbmDecoder::new(BufReader::new( + File::open("tests/images/xbm/hotspot.xbm").unwrap(), + )) + .expect("Unable to read XBM file"); + + assert_eq!((5, 5), decoder.dimensions()); + assert_eq!(Some((-1, 2)), decoder.hotspot()); + } +} diff --git a/tests/images/xbm/1x1.png b/tests/images/xbm/1x1.png new file mode 100644 index 0000000000000000000000000000000000000000..96829e21a1ab3fa1f00255fc3cdfadb9fe839736 GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CSbBp9sfW`_bPUQZXtkP1ddCWgQN8U8aeGBPBz R3vC4{@O1TaS?83{1OSuc4cPzy literal 0 HcmV?d00001 diff --git a/tests/images/xbm/1x1.xbm b/tests/images/xbm/1x1.xbm new file mode 100644 index 0000000..57fb4ea --- /dev/null +++ b/tests/images/xbm/1x1.xbm @@ -0,0 +1,3 @@ +#define onepx_width 1 +#define onepx_height 1 +static char onepx_bits[] = { 0xaA }; diff --git a/tests/images/xbm/hotspot.png b/tests/images/xbm/hotspot.png new file mode 100644 index 0000000000000000000000000000000000000000..4558f1c2f5f1a16b7fc51a9884ed4f1ddb6db2ec GIT binary patch literal 98 zcmeAS@N?(olHy`uVBq!ia0vp^tRTz*Bp6n(R9XTlO-~ockP1ddIfjS-85sTpAue)i V2j_RkmjT@%&7Q7)F6*2UngHYA85;lq literal 0 HcmV?d00001 diff --git a/tests/images/xbm/hotspot.xbm b/tests/images/xbm/hotspot.xbm new file mode 100644 index 0000000..4486894 --- /dev/null +++ b/tests/images/xbm/hotspot.xbm @@ -0,0 +1,6 @@ +#define hotspot_width 5 +#define hotspot_height 5 +#define hotspot_x_hot -1 +#define hotspot_y_hot 2 +static unsigned char hotspot_bits[] = { + 0x55, 0x55, 0x55, 0x55, 0x55 }; diff --git a/tests/images/xbm/spiral.png b/tests/images/xbm/spiral.png new file mode 100644 index 0000000000000000000000000000000000000000..8f09c70f5ab8350accc0633a7095c30b00fcaa77 GIT binary patch literal 189 zcmeAS@N?(olHy`uVBq!ia0vp^f*{NRBpBq_B-DUZi>HfYNCo5D1I!ClBBveu_`m#J z?};hx&|(WM>7{NCA?!7b+F+MIbb6l gBEZie`q|+_|Aa_hXO@$51VHZcboFyt=akR{0NMgVvj6}9 literal 0 HcmV?d00001 diff --git a/tests/images/xbm/spiral.xbm b/tests/images/xbm/spiral.xbm new file mode 100644 index 0000000..9337c68 --- /dev/null +++ b/tests/images/xbm/spiral.xbm @@ -0,0 +1,7 @@ +#define _width 17 +#define _height 17 +static char _bits[] = { + 0x00,0x00,0x00,0xfe,0xff,0x01,0x02,0x00,0x00,0xfa,0xff,0x00,0x0a,0x80,0x00, + 0xea,0xbf,0x00,0x2a,0xa0,0x00,0xaa,0xaf,0x00,0xaa,0xa8,0x00,0xaa,0xab,0x00, + 0x2a,0xa8,0x00,0xea,0xaf,0x00,0x0a,0xa0,0x00,0xfa,0xbf,0x00,0x02,0x80,0x00, + 0xfe,0xff,0x00,0x00,0x00,0x00 }; From 09d37b821575d9494dea65a271a251267fff42f2 Mon Sep 17 00:00:00 2001 From: M Stoeckl Date: Sat, 12 Jul 2025 12:51:49 -0400 Subject: [PATCH 4/4] Add XPM decoder --- Cargo.toml | 4 +- README.md | 1 + deny.toml | 1 + fuzz/Cargo.toml | 6 +- fuzz/fuzzers/fuzzer_script_xbm.rs | 6 +- fuzz/fuzzers/fuzzer_script_xpm.rs | 21 + image-x11r6colors/Cargo.toml | 8 + image-x11r6colors/LICENSE-X11 | 23 + image-x11r6colors/README.md | 12 + image-x11r6colors/src/lib.rs | 777 ++++++++++++++++++++ src/lib.rs | 9 + src/xpm.rs | 1115 +++++++++++++++++++++++++++++ tests/images/xpm/1x1.png | Bin 0 -> 77 bytes tests/images/xpm/1x1.xpm | 8 + tests/images/xpm/gray.png | Bin 0 -> 117 bytes tests/images/xpm/gray.xpm | 10 + tests/images/xpm/green.png | Bin 0 -> 234 bytes tests/images/xpm/green.xpm | 15 + tests/images/xpm/snake.png | Bin 0 -> 336 bytes tests/images/xpm/snake.xpm | 20 + 20 files changed, 2030 insertions(+), 6 deletions(-) create mode 100644 fuzz/fuzzers/fuzzer_script_xpm.rs create mode 100644 image-x11r6colors/Cargo.toml create mode 100644 image-x11r6colors/LICENSE-X11 create mode 100644 image-x11r6colors/README.md create mode 100644 image-x11r6colors/src/lib.rs create mode 100644 src/xpm.rs create mode 100644 tests/images/xpm/1x1.png create mode 100644 tests/images/xpm/1x1.xpm create mode 100644 tests/images/xpm/gray.png create mode 100644 tests/images/xpm/gray.xpm create mode 100644 tests/images/xpm/green.png create mode 100644 tests/images/xpm/green.xpm create mode 100644 tests/images/xpm/snake.png create mode 100644 tests/images/xpm/snake.xpm diff --git a/Cargo.toml b/Cargo.toml index c1fe595..757bad7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,13 +8,15 @@ publish = false include = ["src", "tests/reference.rs"] [features] -default = ["pcx", "xbm"] +default = ["pcx", "xbm", "xpm"] pcx = ["dep:pcx"] xbm = [] +xpm = ["dep:image-x11r6colors"] [dependencies] image = { version = "0.25.5", default-features = false } pcx = { version = "0.2.4", optional = true } +image-x11r6colors = { path = 'image-x11r6colors', version = "1.0.0", optional = true } [dev-dependencies] image = { version = "0.25.5", default-features = false, features = ["png"] } diff --git a/README.md b/README.md index 02a7752..7f38891 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Decoding support for additional image formats beyond those provided by the [`ima | --------- | -------------------- | | PCX | [Wikipedia](https://en.wikipedia.org/wiki/PCX#PCX_file_format) | | XBM | [Wikipedia](https://en.wikipedia.org/wiki/X_BitMap) | +| XPM | [Wikipedia](https://en.wikipedia.org/wiki/X_PixMap) | ## New Formats diff --git a/deny.toml b/deny.toml index f2e2cb8..6f193ba 100644 --- a/deny.toml +++ b/deny.toml @@ -19,6 +19,7 @@ allow = [ "BSD-2-Clause", "BSD-3-Clause", "MIT", + "X11", "MIT-0", "MPL-2.0", "Unicode-DFS-2016", diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 58c76e6..0a81af5 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -14,7 +14,7 @@ image = { version = "0.25.5", default-features = false } [dependencies.image-extras] path = ".." -features = ["xbm"] +features = ["xbm", "xpm"] [dependencies.libfuzzer-sys] version = "0.4" @@ -29,3 +29,7 @@ members = ["."] [[bin]] name = "fuzzer_script_xbm" path = "fuzzers/fuzzer_script_xbm.rs" + +[[bin]] +name = "fuzzer_script_xpm" +path = "fuzzers/fuzzer_script_xpm.rs" diff --git a/fuzz/fuzzers/fuzzer_script_xbm.rs b/fuzz/fuzzers/fuzzer_script_xbm.rs index 7794430..1c3a78e 100644 --- a/fuzz/fuzzers/fuzzer_script_xbm.rs +++ b/fuzz/fuzzers/fuzzer_script_xbm.rs @@ -1,14 +1,12 @@ #![no_main] #[macro_use] extern crate libfuzzer_sys; -extern crate image_extras; -extern crate image; -use std::io::BufReader; +use std::io::Cursor; use image::ImageDecoder; fuzz_target!(|data: &[u8]| { - let reader = BufReader::new(data); + let reader = Cursor::new(data); let Ok(mut decoder) = image_extras::xbm::XbmDecoder::new(reader) else { return; }; diff --git a/fuzz/fuzzers/fuzzer_script_xpm.rs b/fuzz/fuzzers/fuzzer_script_xpm.rs new file mode 100644 index 0000000..02f7968 --- /dev/null +++ b/fuzz/fuzzers/fuzzer_script_xpm.rs @@ -0,0 +1,21 @@ +#![no_main] +#[macro_use] extern crate libfuzzer_sys; + +use std::io::Cursor; +use image::ImageDecoder; + +fuzz_target!(|data: &[u8]| { + let reader = Cursor::new(data); + let Ok(mut decoder) = image_extras::xpm::XpmDecoder::new(reader) else { + return; + }; + let mut limits = image::Limits::default(); + limits.max_alloc = Some(1024 * 1024); // 1 MiB + if limits.reserve(decoder.total_bytes()).is_err() { + return; + } + if decoder.set_limits(limits).is_err() { + return; + } + let _ = std::hint::black_box(image::DynamicImage::from_decoder(decoder)); +}); diff --git a/image-x11r6colors/Cargo.toml b/image-x11r6colors/Cargo.toml new file mode 100644 index 0000000..837554f --- /dev/null +++ b/image-x11r6colors/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "image-x11r6colors" +version = "1.0.0" +license = "(MIT OR Apache-2.0) AND X11" +rust-version = "1.17.0" +description = "Color database matching X11R6" +readme = "README.md" +publish = false diff --git a/image-x11r6colors/LICENSE-X11 b/image-x11r6colors/LICENSE-X11 new file mode 100644 index 0000000..81e8a23 --- /dev/null +++ b/image-x11r6colors/LICENSE-X11 @@ -0,0 +1,23 @@ +Copyright (C) 1994 X Consortium + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to +deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +X CONSORTIUM BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN +AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNEC- +TION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Except as contained in this notice, the name of the X Consortium shall not +be used in advertising or otherwise to promote the sale, use or other deal- +ings in this Software without prior written authorization from the X Consor- +tium. diff --git a/image-x11r6colors/README.md b/image-x11r6colors/README.md new file mode 100644 index 0000000..dc93ae3 --- /dev/null +++ b/image-x11r6colors/README.md @@ -0,0 +1,12 @@ +## X11R6 Color names + +This mini-library contains the color names database (often referred to as +`rgb.txt`) from X11R6, as a sorted array with names case folded. It has been +separated out into a distinct crate because it is possible that databases of +colors with names are copyrightable and therefore subject to the X11 license. +Splitting out the list makes it easier for users of the `image-extras`, which +uses this library if its `xpm` feature is enabled, to keep track of licensing +information. + +Only the color database itself (from 1994) is X11 licensed; everything since +then is offered under `MIT OR Apache-2.0`. diff --git a/image-x11r6colors/src/lib.rs b/image-x11r6colors/src/lib.rs new file mode 100644 index 0000000..57c4420 --- /dev/null +++ b/image-x11r6colors/src/lib.rs @@ -0,0 +1,777 @@ +//! The colors from X11R6. +//! +//! This mini-library contains the color names database (`rgb.txt`) provided by +//! X11R6 (the sixth release of the X Window System, Version 11), with names +//! folded to lowercase because X does case insensitive string comparisons. +//! +//! X11R6 was relased in May of 1994. Since then, the X11 color list was +//! modified in June 2014 to add CSS level 4 colors and add entries +//! distinguishing certain CSS from X11 colors. There have been no other +//! material changes. +//! +//! The color values have been mostly stable since X11R4 (released Dec 1989); +//! X11R5 slightly modified the colors 'gray' and 'grey', and X11R6 only added +//! colors. +//! +//! This list is used by the XPM decoder from the image-extras library. +//! +#![no_std] + +/// Table of colors, sorted by name. +/// Table entries are (name, R, G, B) tuples. +/// Names are lowercase. +/// Some but not all multi-word names have an additional form with spaces between words. +pub const COLORS: [(&str, u8, u8, u8); 752] = [ + ("alice blue", 240, 248, 255), + ("aliceblue", 240, 248, 255), + ("antique white", 250, 235, 215), + ("antiquewhite", 250, 235, 215), + ("antiquewhite1", 255, 239, 219), + ("antiquewhite2", 238, 223, 204), + ("antiquewhite3", 205, 192, 176), + ("antiquewhite4", 139, 131, 120), + ("aquamarine", 127, 255, 212), + ("aquamarine1", 127, 255, 212), + ("aquamarine2", 118, 238, 198), + ("aquamarine3", 102, 205, 170), + ("aquamarine4", 69, 139, 116), + ("azure", 240, 255, 255), + ("azure1", 240, 255, 255), + ("azure2", 224, 238, 238), + ("azure3", 193, 205, 205), + ("azure4", 131, 139, 139), + ("beige", 245, 245, 220), + ("bisque", 255, 228, 196), + ("bisque1", 255, 228, 196), + ("bisque2", 238, 213, 183), + ("bisque3", 205, 183, 158), + ("bisque4", 139, 125, 107), + ("black", 0, 0, 0), + ("blanched almond", 255, 235, 205), + ("blanchedalmond", 255, 235, 205), + ("blue", 0, 0, 255), + ("blue violet", 138, 43, 226), + ("blue1", 0, 0, 255), + ("blue2", 0, 0, 238), + ("blue3", 0, 0, 205), + ("blue4", 0, 0, 139), + ("blueviolet", 138, 43, 226), + ("brown", 165, 42, 42), + ("brown1", 255, 64, 64), + ("brown2", 238, 59, 59), + ("brown3", 205, 51, 51), + ("brown4", 139, 35, 35), + ("burlywood", 222, 184, 135), + ("burlywood1", 255, 211, 155), + ("burlywood2", 238, 197, 145), + ("burlywood3", 205, 170, 125), + ("burlywood4", 139, 115, 85), + ("cadet blue", 95, 158, 160), + ("cadetblue", 95, 158, 160), + ("cadetblue1", 152, 245, 255), + ("cadetblue2", 142, 229, 238), + ("cadetblue3", 122, 197, 205), + ("cadetblue4", 83, 134, 139), + ("chartreuse", 127, 255, 0), + ("chartreuse1", 127, 255, 0), + ("chartreuse2", 118, 238, 0), + ("chartreuse3", 102, 205, 0), + ("chartreuse4", 69, 139, 0), + ("chocolate", 210, 105, 30), + ("chocolate1", 255, 127, 36), + ("chocolate2", 238, 118, 33), + ("chocolate3", 205, 102, 29), + ("chocolate4", 139, 69, 19), + ("coral", 255, 127, 80), + ("coral1", 255, 114, 86), + ("coral2", 238, 106, 80), + ("coral3", 205, 91, 69), + ("coral4", 139, 62, 47), + ("cornflower blue", 100, 149, 237), + ("cornflowerblue", 100, 149, 237), + ("cornsilk", 255, 248, 220), + ("cornsilk1", 255, 248, 220), + ("cornsilk2", 238, 232, 205), + ("cornsilk3", 205, 200, 177), + ("cornsilk4", 139, 136, 120), + ("cyan", 0, 255, 255), + ("cyan1", 0, 255, 255), + ("cyan2", 0, 238, 238), + ("cyan3", 0, 205, 205), + ("cyan4", 0, 139, 139), + ("dark blue", 0, 0, 139), + ("dark cyan", 0, 139, 139), + ("dark goldenrod", 184, 134, 11), + ("dark gray", 169, 169, 169), + ("dark green", 0, 100, 0), + ("dark grey", 169, 169, 169), + ("dark khaki", 189, 183, 107), + ("dark magenta", 139, 0, 139), + ("dark olive green", 85, 107, 47), + ("dark orange", 255, 140, 0), + ("dark orchid", 153, 50, 204), + ("dark red", 139, 0, 0), + ("dark salmon", 233, 150, 122), + ("dark sea green", 143, 188, 143), + ("dark slate blue", 72, 61, 139), + ("dark slate gray", 47, 79, 79), + ("dark slate grey", 47, 79, 79), + ("dark turquoise", 0, 206, 209), + ("dark violet", 148, 0, 211), + ("darkblue", 0, 0, 139), + ("darkcyan", 0, 139, 139), + ("darkgoldenrod", 184, 134, 11), + ("darkgoldenrod1", 255, 185, 15), + ("darkgoldenrod2", 238, 173, 14), + ("darkgoldenrod3", 205, 149, 12), + ("darkgoldenrod4", 139, 101, 8), + ("darkgray", 169, 169, 169), + ("darkgreen", 0, 100, 0), + ("darkgrey", 169, 169, 169), + ("darkkhaki", 189, 183, 107), + ("darkmagenta", 139, 0, 139), + ("darkolivegreen", 85, 107, 47), + ("darkolivegreen1", 202, 255, 112), + ("darkolivegreen2", 188, 238, 104), + ("darkolivegreen3", 162, 205, 90), + ("darkolivegreen4", 110, 139, 61), + ("darkorange", 255, 140, 0), + ("darkorange1", 255, 127, 0), + ("darkorange2", 238, 118, 0), + ("darkorange3", 205, 102, 0), + ("darkorange4", 139, 69, 0), + ("darkorchid", 153, 50, 204), + ("darkorchid1", 191, 62, 255), + ("darkorchid2", 178, 58, 238), + ("darkorchid3", 154, 50, 205), + ("darkorchid4", 104, 34, 139), + ("darkred", 139, 0, 0), + ("darksalmon", 233, 150, 122), + ("darkseagreen", 143, 188, 143), + ("darkseagreen1", 193, 255, 193), + ("darkseagreen2", 180, 238, 180), + ("darkseagreen3", 155, 205, 155), + ("darkseagreen4", 105, 139, 105), + ("darkslateblue", 72, 61, 139), + ("darkslategray", 47, 79, 79), + ("darkslategray1", 151, 255, 255), + ("darkslategray2", 141, 238, 238), + ("darkslategray3", 121, 205, 205), + ("darkslategray4", 82, 139, 139), + ("darkslategrey", 47, 79, 79), + ("darkturquoise", 0, 206, 209), + ("darkviolet", 148, 0, 211), + ("deep pink", 255, 20, 147), + ("deep sky blue", 0, 191, 255), + ("deeppink", 255, 20, 147), + ("deeppink1", 255, 20, 147), + ("deeppink2", 238, 18, 137), + ("deeppink3", 205, 16, 118), + ("deeppink4", 139, 10, 80), + ("deepskyblue", 0, 191, 255), + ("deepskyblue1", 0, 191, 255), + ("deepskyblue2", 0, 178, 238), + ("deepskyblue3", 0, 154, 205), + ("deepskyblue4", 0, 104, 139), + ("dim gray", 105, 105, 105), + ("dim grey", 105, 105, 105), + ("dimgray", 105, 105, 105), + ("dimgrey", 105, 105, 105), + ("dodger blue", 30, 144, 255), + ("dodgerblue", 30, 144, 255), + ("dodgerblue1", 30, 144, 255), + ("dodgerblue2", 28, 134, 238), + ("dodgerblue3", 24, 116, 205), + ("dodgerblue4", 16, 78, 139), + ("firebrick", 178, 34, 34), + ("firebrick1", 255, 48, 48), + ("firebrick2", 238, 44, 44), + ("firebrick3", 205, 38, 38), + ("firebrick4", 139, 26, 26), + ("floral white", 255, 250, 240), + ("floralwhite", 255, 250, 240), + ("forest green", 34, 139, 34), + ("forestgreen", 34, 139, 34), + ("gainsboro", 220, 220, 220), + ("ghost white", 248, 248, 255), + ("ghostwhite", 248, 248, 255), + ("gold", 255, 215, 0), + ("gold1", 255, 215, 0), + ("gold2", 238, 201, 0), + ("gold3", 205, 173, 0), + ("gold4", 139, 117, 0), + ("goldenrod", 218, 165, 32), + ("goldenrod1", 255, 193, 37), + ("goldenrod2", 238, 180, 34), + ("goldenrod3", 205, 155, 29), + ("goldenrod4", 139, 105, 20), + ("gray", 190, 190, 190), + ("gray0", 0, 0, 0), + ("gray1", 3, 3, 3), + ("gray10", 26, 26, 26), + ("gray100", 255, 255, 255), + ("gray11", 28, 28, 28), + ("gray12", 31, 31, 31), + ("gray13", 33, 33, 33), + ("gray14", 36, 36, 36), + ("gray15", 38, 38, 38), + ("gray16", 41, 41, 41), + ("gray17", 43, 43, 43), + ("gray18", 46, 46, 46), + ("gray19", 48, 48, 48), + ("gray2", 5, 5, 5), + ("gray20", 51, 51, 51), + ("gray21", 54, 54, 54), + ("gray22", 56, 56, 56), + ("gray23", 59, 59, 59), + ("gray24", 61, 61, 61), + ("gray25", 64, 64, 64), + ("gray26", 66, 66, 66), + ("gray27", 69, 69, 69), + ("gray28", 71, 71, 71), + ("gray29", 74, 74, 74), + ("gray3", 8, 8, 8), + ("gray30", 77, 77, 77), + ("gray31", 79, 79, 79), + ("gray32", 82, 82, 82), + ("gray33", 84, 84, 84), + ("gray34", 87, 87, 87), + ("gray35", 89, 89, 89), + ("gray36", 92, 92, 92), + ("gray37", 94, 94, 94), + ("gray38", 97, 97, 97), + ("gray39", 99, 99, 99), + ("gray4", 10, 10, 10), + ("gray40", 102, 102, 102), + ("gray41", 105, 105, 105), + ("gray42", 107, 107, 107), + ("gray43", 110, 110, 110), + ("gray44", 112, 112, 112), + ("gray45", 115, 115, 115), + ("gray46", 117, 117, 117), + ("gray47", 120, 120, 120), + ("gray48", 122, 122, 122), + ("gray49", 125, 125, 125), + ("gray5", 13, 13, 13), + ("gray50", 127, 127, 127), + ("gray51", 130, 130, 130), + ("gray52", 133, 133, 133), + ("gray53", 135, 135, 135), + ("gray54", 138, 138, 138), + ("gray55", 140, 140, 140), + ("gray56", 143, 143, 143), + ("gray57", 145, 145, 145), + ("gray58", 148, 148, 148), + ("gray59", 150, 150, 150), + ("gray6", 15, 15, 15), + ("gray60", 153, 153, 153), + ("gray61", 156, 156, 156), + ("gray62", 158, 158, 158), + ("gray63", 161, 161, 161), + ("gray64", 163, 163, 163), + ("gray65", 166, 166, 166), + ("gray66", 168, 168, 168), + ("gray67", 171, 171, 171), + ("gray68", 173, 173, 173), + ("gray69", 176, 176, 176), + ("gray7", 18, 18, 18), + ("gray70", 179, 179, 179), + ("gray71", 181, 181, 181), + ("gray72", 184, 184, 184), + ("gray73", 186, 186, 186), + ("gray74", 189, 189, 189), + ("gray75", 191, 191, 191), + ("gray76", 194, 194, 194), + ("gray77", 196, 196, 196), + ("gray78", 199, 199, 199), + ("gray79", 201, 201, 201), + ("gray8", 20, 20, 20), + ("gray80", 204, 204, 204), + ("gray81", 207, 207, 207), + ("gray82", 209, 209, 209), + ("gray83", 212, 212, 212), + ("gray84", 214, 214, 214), + ("gray85", 217, 217, 217), + ("gray86", 219, 219, 219), + ("gray87", 222, 222, 222), + ("gray88", 224, 224, 224), + ("gray89", 227, 227, 227), + ("gray9", 23, 23, 23), + ("gray90", 229, 229, 229), + ("gray91", 232, 232, 232), + ("gray92", 235, 235, 235), + ("gray93", 237, 237, 237), + ("gray94", 240, 240, 240), + ("gray95", 242, 242, 242), + ("gray96", 245, 245, 245), + ("gray97", 247, 247, 247), + ("gray98", 250, 250, 250), + ("gray99", 252, 252, 252), + ("green", 0, 255, 0), + ("green yellow", 173, 255, 47), + ("green1", 0, 255, 0), + ("green2", 0, 238, 0), + ("green3", 0, 205, 0), + ("green4", 0, 139, 0), + ("greenyellow", 173, 255, 47), + ("grey", 190, 190, 190), + ("grey0", 0, 0, 0), + ("grey1", 3, 3, 3), + ("grey10", 26, 26, 26), + ("grey100", 255, 255, 255), + ("grey11", 28, 28, 28), + ("grey12", 31, 31, 31), + ("grey13", 33, 33, 33), + ("grey14", 36, 36, 36), + ("grey15", 38, 38, 38), + ("grey16", 41, 41, 41), + ("grey17", 43, 43, 43), + ("grey18", 46, 46, 46), + ("grey19", 48, 48, 48), + ("grey2", 5, 5, 5), + ("grey20", 51, 51, 51), + ("grey21", 54, 54, 54), + ("grey22", 56, 56, 56), + ("grey23", 59, 59, 59), + ("grey24", 61, 61, 61), + ("grey25", 64, 64, 64), + ("grey26", 66, 66, 66), + ("grey27", 69, 69, 69), + ("grey28", 71, 71, 71), + ("grey29", 74, 74, 74), + ("grey3", 8, 8, 8), + ("grey30", 77, 77, 77), + ("grey31", 79, 79, 79), + ("grey32", 82, 82, 82), + ("grey33", 84, 84, 84), + ("grey34", 87, 87, 87), + ("grey35", 89, 89, 89), + ("grey36", 92, 92, 92), + ("grey37", 94, 94, 94), + ("grey38", 97, 97, 97), + ("grey39", 99, 99, 99), + ("grey4", 10, 10, 10), + ("grey40", 102, 102, 102), + ("grey41", 105, 105, 105), + ("grey42", 107, 107, 107), + ("grey43", 110, 110, 110), + ("grey44", 112, 112, 112), + ("grey45", 115, 115, 115), + ("grey46", 117, 117, 117), + ("grey47", 120, 120, 120), + ("grey48", 122, 122, 122), + ("grey49", 125, 125, 125), + ("grey5", 13, 13, 13), + ("grey50", 127, 127, 127), + ("grey51", 130, 130, 130), + ("grey52", 133, 133, 133), + ("grey53", 135, 135, 135), + ("grey54", 138, 138, 138), + ("grey55", 140, 140, 140), + ("grey56", 143, 143, 143), + ("grey57", 145, 145, 145), + ("grey58", 148, 148, 148), + ("grey59", 150, 150, 150), + ("grey6", 15, 15, 15), + ("grey60", 153, 153, 153), + ("grey61", 156, 156, 156), + ("grey62", 158, 158, 158), + ("grey63", 161, 161, 161), + ("grey64", 163, 163, 163), + ("grey65", 166, 166, 166), + ("grey66", 168, 168, 168), + ("grey67", 171, 171, 171), + ("grey68", 173, 173, 173), + ("grey69", 176, 176, 176), + ("grey7", 18, 18, 18), + ("grey70", 179, 179, 179), + ("grey71", 181, 181, 181), + ("grey72", 184, 184, 184), + ("grey73", 186, 186, 186), + ("grey74", 189, 189, 189), + ("grey75", 191, 191, 191), + ("grey76", 194, 194, 194), + ("grey77", 196, 196, 196), + ("grey78", 199, 199, 199), + ("grey79", 201, 201, 201), + ("grey8", 20, 20, 20), + ("grey80", 204, 204, 204), + ("grey81", 207, 207, 207), + ("grey82", 209, 209, 209), + ("grey83", 212, 212, 212), + ("grey84", 214, 214, 214), + ("grey85", 217, 217, 217), + ("grey86", 219, 219, 219), + ("grey87", 222, 222, 222), + ("grey88", 224, 224, 224), + ("grey89", 227, 227, 227), + ("grey9", 23, 23, 23), + ("grey90", 229, 229, 229), + ("grey91", 232, 232, 232), + ("grey92", 235, 235, 235), + ("grey93", 237, 237, 237), + ("grey94", 240, 240, 240), + ("grey95", 242, 242, 242), + ("grey96", 245, 245, 245), + ("grey97", 247, 247, 247), + ("grey98", 250, 250, 250), + ("grey99", 252, 252, 252), + ("honeydew", 240, 255, 240), + ("honeydew1", 240, 255, 240), + ("honeydew2", 224, 238, 224), + ("honeydew3", 193, 205, 193), + ("honeydew4", 131, 139, 131), + ("hot pink", 255, 105, 180), + ("hotpink", 255, 105, 180), + ("hotpink1", 255, 110, 180), + ("hotpink2", 238, 106, 167), + ("hotpink3", 205, 96, 144), + ("hotpink4", 139, 58, 98), + ("indian red", 205, 92, 92), + ("indianred", 205, 92, 92), + ("indianred1", 255, 106, 106), + ("indianred2", 238, 99, 99), + ("indianred3", 205, 85, 85), + ("indianred4", 139, 58, 58), + ("ivory", 255, 255, 240), + ("ivory1", 255, 255, 240), + ("ivory2", 238, 238, 224), + ("ivory3", 205, 205, 193), + ("ivory4", 139, 139, 131), + ("khaki", 240, 230, 140), + ("khaki1", 255, 246, 143), + ("khaki2", 238, 230, 133), + ("khaki3", 205, 198, 115), + ("khaki4", 139, 134, 78), + ("lavender", 230, 230, 250), + ("lavender blush", 255, 240, 245), + ("lavenderblush", 255, 240, 245), + ("lavenderblush1", 255, 240, 245), + ("lavenderblush2", 238, 224, 229), + ("lavenderblush3", 205, 193, 197), + ("lavenderblush4", 139, 131, 134), + ("lawn green", 124, 252, 0), + ("lawngreen", 124, 252, 0), + ("lemon chiffon", 255, 250, 205), + ("lemonchiffon", 255, 250, 205), + ("lemonchiffon1", 255, 250, 205), + ("lemonchiffon2", 238, 233, 191), + ("lemonchiffon3", 205, 201, 165), + ("lemonchiffon4", 139, 137, 112), + ("light blue", 173, 216, 230), + ("light coral", 240, 128, 128), + ("light cyan", 224, 255, 255), + ("light goldenrod", 238, 221, 130), + ("light goldenrod yellow", 250, 250, 210), + ("light gray", 211, 211, 211), + ("light green", 144, 238, 144), + ("light grey", 211, 211, 211), + ("light pink", 255, 182, 193), + ("light salmon", 255, 160, 122), + ("light sea green", 32, 178, 170), + ("light sky blue", 135, 206, 250), + ("light slate blue", 132, 112, 255), + ("light slate gray", 119, 136, 153), + ("light slate grey", 119, 136, 153), + ("light steel blue", 176, 196, 222), + ("light yellow", 255, 255, 224), + ("lightblue", 173, 216, 230), + ("lightblue1", 191, 239, 255), + ("lightblue2", 178, 223, 238), + ("lightblue3", 154, 192, 205), + ("lightblue4", 104, 131, 139), + ("lightcoral", 240, 128, 128), + ("lightcyan", 224, 255, 255), + ("lightcyan1", 224, 255, 255), + ("lightcyan2", 209, 238, 238), + ("lightcyan3", 180, 205, 205), + ("lightcyan4", 122, 139, 139), + ("lightgoldenrod", 238, 221, 130), + ("lightgoldenrod1", 255, 236, 139), + ("lightgoldenrod2", 238, 220, 130), + ("lightgoldenrod3", 205, 190, 112), + ("lightgoldenrod4", 139, 129, 76), + ("lightgoldenrodyellow", 250, 250, 210), + ("lightgray", 211, 211, 211), + ("lightgreen", 144, 238, 144), + ("lightgrey", 211, 211, 211), + ("lightpink", 255, 182, 193), + ("lightpink1", 255, 174, 185), + ("lightpink2", 238, 162, 173), + ("lightpink3", 205, 140, 149), + ("lightpink4", 139, 95, 101), + ("lightsalmon", 255, 160, 122), + ("lightsalmon1", 255, 160, 122), + ("lightsalmon2", 238, 149, 114), + ("lightsalmon3", 205, 129, 98), + ("lightsalmon4", 139, 87, 66), + ("lightseagreen", 32, 178, 170), + ("lightskyblue", 135, 206, 250), + ("lightskyblue1", 176, 226, 255), + ("lightskyblue2", 164, 211, 238), + ("lightskyblue3", 141, 182, 205), + ("lightskyblue4", 96, 123, 139), + ("lightslateblue", 132, 112, 255), + ("lightslategray", 119, 136, 153), + ("lightslategrey", 119, 136, 153), + ("lightsteelblue", 176, 196, 222), + ("lightsteelblue1", 202, 225, 255), + ("lightsteelblue2", 188, 210, 238), + ("lightsteelblue3", 162, 181, 205), + ("lightsteelblue4", 110, 123, 139), + ("lightyellow", 255, 255, 224), + ("lightyellow1", 255, 255, 224), + ("lightyellow2", 238, 238, 209), + ("lightyellow3", 205, 205, 180), + ("lightyellow4", 139, 139, 122), + ("lime green", 50, 205, 50), + ("limegreen", 50, 205, 50), + ("linen", 250, 240, 230), + ("magenta", 255, 0, 255), + ("magenta1", 255, 0, 255), + ("magenta2", 238, 0, 238), + ("magenta3", 205, 0, 205), + ("magenta4", 139, 0, 139), + ("maroon", 176, 48, 96), + ("maroon1", 255, 52, 179), + ("maroon2", 238, 48, 167), + ("maroon3", 205, 41, 144), + ("maroon4", 139, 28, 98), + ("medium aquamarine", 102, 205, 170), + ("medium blue", 0, 0, 205), + ("medium orchid", 186, 85, 211), + ("medium purple", 147, 112, 219), + ("medium sea green", 60, 179, 113), + ("medium slate blue", 123, 104, 238), + ("medium spring green", 0, 250, 154), + ("medium turquoise", 72, 209, 204), + ("medium violet red", 199, 21, 133), + ("mediumaquamarine", 102, 205, 170), + ("mediumblue", 0, 0, 205), + ("mediumorchid", 186, 85, 211), + ("mediumorchid1", 224, 102, 255), + ("mediumorchid2", 209, 95, 238), + ("mediumorchid3", 180, 82, 205), + ("mediumorchid4", 122, 55, 139), + ("mediumpurple", 147, 112, 219), + ("mediumpurple1", 171, 130, 255), + ("mediumpurple2", 159, 121, 238), + ("mediumpurple3", 137, 104, 205), + ("mediumpurple4", 93, 71, 139), + ("mediumseagreen", 60, 179, 113), + ("mediumslateblue", 123, 104, 238), + ("mediumspringgreen", 0, 250, 154), + ("mediumturquoise", 72, 209, 204), + ("mediumvioletred", 199, 21, 133), + ("midnight blue", 25, 25, 112), + ("midnightblue", 25, 25, 112), + ("mint cream", 245, 255, 250), + ("mintcream", 245, 255, 250), + ("misty rose", 255, 228, 225), + ("mistyrose", 255, 228, 225), + ("mistyrose1", 255, 228, 225), + ("mistyrose2", 238, 213, 210), + ("mistyrose3", 205, 183, 181), + ("mistyrose4", 139, 125, 123), + ("moccasin", 255, 228, 181), + ("navajo white", 255, 222, 173), + ("navajowhite", 255, 222, 173), + ("navajowhite1", 255, 222, 173), + ("navajowhite2", 238, 207, 161), + ("navajowhite3", 205, 179, 139), + ("navajowhite4", 139, 121, 94), + ("navy", 0, 0, 128), + ("navy blue", 0, 0, 128), + ("navyblue", 0, 0, 128), + ("old lace", 253, 245, 230), + ("oldlace", 253, 245, 230), + ("olive drab", 107, 142, 35), + ("olivedrab", 107, 142, 35), + ("olivedrab1", 192, 255, 62), + ("olivedrab2", 179, 238, 58), + ("olivedrab3", 154, 205, 50), + ("olivedrab4", 105, 139, 34), + ("orange", 255, 165, 0), + ("orange red", 255, 69, 0), + ("orange1", 255, 165, 0), + ("orange2", 238, 154, 0), + ("orange3", 205, 133, 0), + ("orange4", 139, 90, 0), + ("orangered", 255, 69, 0), + ("orangered1", 255, 69, 0), + ("orangered2", 238, 64, 0), + ("orangered3", 205, 55, 0), + ("orangered4", 139, 37, 0), + ("orchid", 218, 112, 214), + ("orchid1", 255, 131, 250), + ("orchid2", 238, 122, 233), + ("orchid3", 205, 105, 201), + ("orchid4", 139, 71, 137), + ("pale goldenrod", 238, 232, 170), + ("pale green", 152, 251, 152), + ("pale turquoise", 175, 238, 238), + ("pale violet red", 219, 112, 147), + ("palegoldenrod", 238, 232, 170), + ("palegreen", 152, 251, 152), + ("palegreen1", 154, 255, 154), + ("palegreen2", 144, 238, 144), + ("palegreen3", 124, 205, 124), + ("palegreen4", 84, 139, 84), + ("paleturquoise", 175, 238, 238), + ("paleturquoise1", 187, 255, 255), + ("paleturquoise2", 174, 238, 238), + ("paleturquoise3", 150, 205, 205), + ("paleturquoise4", 102, 139, 139), + ("palevioletred", 219, 112, 147), + ("palevioletred1", 255, 130, 171), + ("palevioletred2", 238, 121, 159), + ("palevioletred3", 205, 104, 137), + ("palevioletred4", 139, 71, 93), + ("papaya whip", 255, 239, 213), + ("papayawhip", 255, 239, 213), + ("peach puff", 255, 218, 185), + ("peachpuff", 255, 218, 185), + ("peachpuff1", 255, 218, 185), + ("peachpuff2", 238, 203, 173), + ("peachpuff3", 205, 175, 149), + ("peachpuff4", 139, 119, 101), + ("peru", 205, 133, 63), + ("pink", 255, 192, 203), + ("pink1", 255, 181, 197), + ("pink2", 238, 169, 184), + ("pink3", 205, 145, 158), + ("pink4", 139, 99, 108), + ("plum", 221, 160, 221), + ("plum1", 255, 187, 255), + ("plum2", 238, 174, 238), + ("plum3", 205, 150, 205), + ("plum4", 139, 102, 139), + ("powder blue", 176, 224, 230), + ("powderblue", 176, 224, 230), + ("purple", 160, 32, 240), + ("purple1", 155, 48, 255), + ("purple2", 145, 44, 238), + ("purple3", 125, 38, 205), + ("purple4", 85, 26, 139), + ("red", 255, 0, 0), + ("red1", 255, 0, 0), + ("red2", 238, 0, 0), + ("red3", 205, 0, 0), + ("red4", 139, 0, 0), + ("rosy brown", 188, 143, 143), + ("rosybrown", 188, 143, 143), + ("rosybrown1", 255, 193, 193), + ("rosybrown2", 238, 180, 180), + ("rosybrown3", 205, 155, 155), + ("rosybrown4", 139, 105, 105), + ("royal blue", 65, 105, 225), + ("royalblue", 65, 105, 225), + ("royalblue1", 72, 118, 255), + ("royalblue2", 67, 110, 238), + ("royalblue3", 58, 95, 205), + ("royalblue4", 39, 64, 139), + ("saddle brown", 139, 69, 19), + ("saddlebrown", 139, 69, 19), + ("salmon", 250, 128, 114), + ("salmon1", 255, 140, 105), + ("salmon2", 238, 130, 98), + ("salmon3", 205, 112, 84), + ("salmon4", 139, 76, 57), + ("sandy brown", 244, 164, 96), + ("sandybrown", 244, 164, 96), + ("sea green", 46, 139, 87), + ("seagreen", 46, 139, 87), + ("seagreen1", 84, 255, 159), + ("seagreen2", 78, 238, 148), + ("seagreen3", 67, 205, 128), + ("seagreen4", 46, 139, 87), + ("seashell", 255, 245, 238), + ("seashell1", 255, 245, 238), + ("seashell2", 238, 229, 222), + ("seashell3", 205, 197, 191), + ("seashell4", 139, 134, 130), + ("sienna", 160, 82, 45), + ("sienna1", 255, 130, 71), + ("sienna2", 238, 121, 66), + ("sienna3", 205, 104, 57), + ("sienna4", 139, 71, 38), + ("sky blue", 135, 206, 235), + ("skyblue", 135, 206, 235), + ("skyblue1", 135, 206, 255), + ("skyblue2", 126, 192, 238), + ("skyblue3", 108, 166, 205), + ("skyblue4", 74, 112, 139), + ("slate blue", 106, 90, 205), + ("slate gray", 112, 128, 144), + ("slate grey", 112, 128, 144), + ("slateblue", 106, 90, 205), + ("slateblue1", 131, 111, 255), + ("slateblue2", 122, 103, 238), + ("slateblue3", 105, 89, 205), + ("slateblue4", 71, 60, 139), + ("slategray", 112, 128, 144), + ("slategray1", 198, 226, 255), + ("slategray2", 185, 211, 238), + ("slategray3", 159, 182, 205), + ("slategray4", 108, 123, 139), + ("slategrey", 112, 128, 144), + ("snow", 255, 250, 250), + ("snow1", 255, 250, 250), + ("snow2", 238, 233, 233), + ("snow3", 205, 201, 201), + ("snow4", 139, 137, 137), + ("spring green", 0, 255, 127), + ("springgreen", 0, 255, 127), + ("springgreen1", 0, 255, 127), + ("springgreen2", 0, 238, 118), + ("springgreen3", 0, 205, 102), + ("springgreen4", 0, 139, 69), + ("steel blue", 70, 130, 180), + ("steelblue", 70, 130, 180), + ("steelblue1", 99, 184, 255), + ("steelblue2", 92, 172, 238), + ("steelblue3", 79, 148, 205), + ("steelblue4", 54, 100, 139), + ("tan", 210, 180, 140), + ("tan1", 255, 165, 79), + ("tan2", 238, 154, 73), + ("tan3", 205, 133, 63), + ("tan4", 139, 90, 43), + ("thistle", 216, 191, 216), + ("thistle1", 255, 225, 255), + ("thistle2", 238, 210, 238), + ("thistle3", 205, 181, 205), + ("thistle4", 139, 123, 139), + ("tomato", 255, 99, 71), + ("tomato1", 255, 99, 71), + ("tomato2", 238, 92, 66), + ("tomato3", 205, 79, 57), + ("tomato4", 139, 54, 38), + ("turquoise", 64, 224, 208), + ("turquoise1", 0, 245, 255), + ("turquoise2", 0, 229, 238), + ("turquoise3", 0, 197, 205), + ("turquoise4", 0, 134, 139), + ("violet", 238, 130, 238), + ("violet red", 208, 32, 144), + ("violetred", 208, 32, 144), + ("violetred1", 255, 62, 150), + ("violetred2", 238, 58, 140), + ("violetred3", 205, 50, 120), + ("violetred4", 139, 34, 82), + ("wheat", 245, 222, 179), + ("wheat1", 255, 231, 186), + ("wheat2", 238, 216, 174), + ("wheat3", 205, 186, 150), + ("wheat4", 139, 126, 102), + ("white", 255, 255, 255), + ("white smoke", 245, 245, 245), + ("whitesmoke", 245, 245, 245), + ("yellow", 255, 255, 0), + ("yellow green", 154, 205, 50), + ("yellow1", 255, 255, 0), + ("yellow2", 238, 238, 0), + ("yellow3", 205, 205, 0), + ("yellow4", 139, 139, 0), + ("yellowgreen", 154, 205, 50), +]; diff --git a/src/lib.rs b/src/lib.rs index 78a1fc9..b0f613e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,6 +21,9 @@ pub mod pcx; #[cfg(feature = "xbm")] pub mod xbm; +#[cfg(feature = "xpm")] +pub mod xpm; + /// Register all enabled extra formats with the image crate. pub fn register() { image::hooks::register_decoding_hook( @@ -38,4 +41,10 @@ pub fn register() { "bm".into(), Box::new(|r| Ok(Box::new(xbm::XbmDecoder::new(r)?))), ); + + image::hooks::register_decoding_hook( + "xpm".into(), + Box::new(|r| Ok(Box::new(xpm::XpmDecoder::new(r)?))), + ); + image::hooks::register_format_detection_hook("xpm".into(), b"/* XPM */", None); } diff --git a/src/xpm.rs b/src/xpm.rs new file mode 100644 index 0000000..883fda4 --- /dev/null +++ b/src/xpm.rs @@ -0,0 +1,1115 @@ +//! Decoding of XPM Images +//! +//! XPM (X PixMap) Format is a plain text image format, originally designed to store +//! cursor and icon data. XPM images are valid C code. +//! +//! (This format is obsolete and nobody should make new images in it. If you need to +//! include an image in a C program, use `xxd -i` or #embed.) +//! +//! The XPM format allows for encoding an image which can be expressed differently +//! depending on the display capabilities (X11 visual), providing specialized versions +//! for color, grayscale, black and white, etc. output in the same image. In practice, +//! most XPM images created after the mid 1990s only provide a variant for the color +//! visual. As a result, this decoder implementation only outputs the color version +//! of the input image. +//! +//! A number of features of the original libXpm are not supported (because they appear to very +//! rarely have been used): +//! - XPMEXT extensions +//! - HSV color specifications +//! - Output for non-color visuals +//! - More relaxed header comment parsing (allowing different whitespace around `XPM` in `/* XPM */`) +//! - Loading with a different color table +//! +//! This is a somewhat strict decoder and will reject many broken image files, including: +//! - those using the XPM2 header or `static char ** name = {` array string +//! - those missing a trailing "," on lines, or which use ";" instead of "," +//! - those with color data lines that are too long +//! - those which have content after the final semicolon which is not a C comment +//! +//! Note: color values for the X11 color name table were _changed_ for the X11R4 release +//! in Dec 1989; since then there have only been additions. +//! +//! This overlaps with XPM version development: XPMv1 in Feb 1989, XPMv2 in Feb-August 1990, +//! and XPMv3 in April 1991. Therefore, if you _do_ see an ancient XPMv1 or XPMv2 file +//! somewhere, it may be using different color name values. +//! +//! This decoder uses the X11 color name table as of X11R6 (May 1994); the only additions since +//! then, in 2014 to add some CSS color names, are _not_ included, to preserve compatibility +//! with other XPM parsers. +//! +//! # Related Links +//! * - XPM Manual version 3.4i, which specifies the format +//! * - XPM Paper +//! * - The XPM format on wikipedia +//! * - XPM format history +//! * - X color names +//! * - Introduction of modern X11 color name table +//! * - more historical XPM material +#![forbid(unsafe_code)] + +use std::cmp::Ordering; +use std::fmt; +use std::io::{BufRead, Bytes}; + +use image::error::{ + DecodingError, ImageError, ImageFormatHint, ImageResult, LimitError, LimitErrorKind, +}; +use image::{ColorType, ImageDecoder, LimitSupport, Limits}; + +/// Maximum length of an X11/CSS/etc. color name is 20; and of an RGB color is 13 +const MAX_COLOR_NAME_LEN: usize = 32; + +/// Location of a byte in the input stream. +/// +/// Includes byte offset (for format debugging with hex editor) and +/// line:column offset (for format debugging with text editor) +#[derive(Clone, Copy, Debug)] +struct TextLocation { + byte: u64, + line: u64, + column: u64, +} + +/// A peekable reader which tracks location information +struct TextReader> { + inner: R, + + current: Option, + + location: TextLocation, +} + +impl TextReader +where + R: Iterator, +{ + /// Initialize a TextReader + fn new(mut r: R) -> TextReader { + let current = r.next(); + TextReader { + inner: r, + current, + location: TextLocation { + byte: 0, + line: 1, + column: 0, + }, + } + } + + /// Consume the next byte. On EOF, will return None + fn next(&mut self) -> Option { + self.current?; + + let mut current = self.inner.next(); + std::mem::swap(&mut self.current, &mut current); + + self.location.byte += 1; + self.location.column += 1; + if let Some(b'\n') = current { + self.location.line += 1; + self.location.column = 0; + } + current + } + /// Peek at the next byte. On EOF, will return None + fn peek(&self) -> Option { + self.current + } + /// The location of the last byte returned by [Self::next] + fn loc(&self) -> TextLocation { + self.location + } +} + +/// Helper struct to project BufRead down to Iterator. Costs of this simple +/// lifetime-free abstraction include that the struct requires space to store the +/// error value, and that code using this must eventually check the error field. +struct IoAdapter { + reader: Bytes, + error: Option, +} + +impl Iterator for IoAdapter +where + R: BufRead, +{ + type Item = u8; + #[inline(always)] + fn next(&mut self) -> Option { + if self.error.is_some() { + return None; + } + match self.reader.next() { + None => None, + Some(Ok(v)) => Some(v), + Some(Err(e)) => { + self.error = Some(e); + None + } + } + } +} + +/// XPM decoder +pub struct XpmDecoder { + r: TextReader>, + info: XpmHeaderInfo, +} + +/// Key XPM file properties determined from first line +struct XpmHeaderInfo { + width: u32, + height: u32, + ncolors: u32, + /// characters per pixel + cpp: u32, +} + +/// XPM color palette storage +struct XpmPalette { + /// Sorted table of color code entries. There are many possible ways to store + /// this, and the fastest approach depends on the image structure, number of pixels, + /// and number of colors. While not as efficient to construct as an unsorted list, + /// or as efficient to look values up in as a perfect hash table, the sorted table + /// performs decently well as long as the palette is small enough to fit in CPU caches. + table: Vec, +} + +/// Pixel code and value read from the Colors section of an XPM file +struct XpmColorCodeEntry { + code: u64, + /// channel order: R,G,B,A + value: [u16; 4], +} + +#[derive(Debug, Clone, Copy)] +enum XpmPart { + Header, + ArrayStart, + FirstLine, + Palette, + Body, + Trailing, + AfterEnd, +} + +#[derive(Debug)] +enum XpmDecodeError { + Parse(XpmPart, TextLocation), + ZeroWidth, + ZeroHeight, + ZeroColors, + BadCharsPerColor(u32), + // A color with the given name is not available. + // Name provided in buffer, length format, and should be alphanumeric ASCII + UnknownColor(([u8; MAX_COLOR_NAME_LEN], u8)), + // Palette entry is missing 'c'-type color specification + NoColorModeColorSpecified, + BadHexColor, + DuplicateCode, + UnknownCode, + TwoKeysInARow, + MissingEntry, + MissingColorAfterKey, + MissingKeyBeforeColor, + InvalidColorName, + ColorNameTooLong, +} + +/// Types of visuals for which a color should be used +#[derive(Debug)] +enum XpmVisual { + Mono, + Symbolic, + Grayscale4, + Grayscale, + Color, +} + +impl fmt::Display for TextLocation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_fmt(format_args!( + "byte={},line={}:col={}", + self.byte, self.line, self.column + )) + } +} + +impl fmt::Display for XpmPart { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Header => f.write_str("header"), + Self::ArrayStart => f.write_str("array definition"), + Self::FirstLine => f.write_str(" section"), + Self::Palette => f.write_str(" section"), + Self::Body => f.write_str(" section"), + Self::Trailing => f.write_str("array end"), + Self::AfterEnd => f.write_str("after final semicolon"), + } + } +} + +impl fmt::Display for XpmDecodeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Parse(part, loc) => f.write_fmt(format_args!("Failed to parse {}, at {}", part, loc)), + Self::ZeroWidth => f.write_str("Invalid (zero) image width"), + Self::ZeroHeight => f.write_str("Invalid (zero) image height"), + Self::ZeroColors => f.write_str("Invalid (zero) number of colors"), + Self::BadCharsPerColor(c) => f.write_fmt(format_args!( + "Invalid number of characters per color: {} is not in [1,8]", + c + )), + Self::UnknownColor((buf, len)) => { + let s = std::str::from_utf8(&buf[..*len as usize]).ok().unwrap_or(""); + assert!(s.chars().all(|x| x.is_ascii_alphanumeric())); + f.write_fmt(format_args!("Unknown color name \"{}\"; is not an X11R6 color.", s)) + } + Self::NoColorModeColorSpecified => { + f.write_str("Color entry has no specified value for color visual") + } + Self::BadHexColor => f.write_str("Invalid hex RGB color"), + Self::DuplicateCode => f.write_str("Duplicate color code"), + Self::UnknownCode => f.write_str("Unknown color code"), + + Self::ColorNameTooLong => f.write_str("Invalid color name, too long"), + Self::TwoKeysInARow => f.write_str("Invalid color specification, two keys in a row"), + Self::MissingEntry => f.write_str("Invalid color specification, must contain at least one key-color pair"), + Self::MissingColorAfterKey => f.write_str("Invalid color specification, no color name after key"), + Self::MissingKeyBeforeColor => f.write_str("Invalid color specification, no key before color name or could not parse value as key (m|s|g4|g|c)"), + Self::InvalidColorName => f.write_str("Invalid color name, contains non-alphanumeric or non-whitespace characters"), + } + } +} + +impl std::error::Error for XpmDecodeError {} + +impl From for ImageError { + fn from(e: XpmDecodeError) -> ImageError { + ImageError::Decoding(DecodingError::new(ImageFormatHint::Name("XPM".into()), e)) + } +} + +/// Helper trait for the pattern in which, after calling a function returning a Result, +/// one wishes to use an error from a different source. +trait XpmDecoderIoInjectionExt { + type Value; + fn apply_after(self, err: &mut Option) -> Result; +} + +impl XpmDecoderIoInjectionExt for Result { + type Value = X; + fn apply_after(self, err: &mut Option) -> Result { + if let Some(err) = err.take() { + return Err(ImageError::IoError(err)); + } + match self { + Self::Ok(x) => Ok(x), + Self::Err(e) => Err(e.into()), + } + } +} + +/// Is x a valid character to use in a word of a color name +fn valid_name_char(x: u8) -> bool { + // underscore: used in some symbolic names + matches!(x, b'#' | b'0'..=b'9' | b'a'..=b'z' | b'A'..=b'Z' | b'_') +} +/// Replace upper case by lower case ASCII letters +fn fold_to_lower(x: u8) -> u8 { + match x { + b'A'..=b'Z' => (x - b'A') + b'a', + _ => x, + } +} + +/// Read precisely the string `s` from `r`, or error. +fn read_fixed_string>( + r: &mut TextReader, + s: &[u8], + part: XpmPart, +) -> Result<(), XpmDecodeError> { + for c in s { + if let Some(b) = r.next() { + if b != *c { + return Err(XpmDecodeError::Parse(part, r.loc())); + } + } else { + return Err(XpmDecodeError::Parse(part, r.loc())); + }; + } + Ok(()) +} +// Read a single byte +fn read_byte>( + r: &mut TextReader, + part: XpmPart, +) -> Result { + match r.next() { + None => Err(XpmDecodeError::Parse(part, r.loc())), + Some(b) => Ok(b), + } +} + +/// Read a mixture of ' ' and '\t'. At least one character must be read. +// Other whitespace characters are not permitted. +fn read_whitespace_gap>( + r: &mut TextReader, + part: XpmPart, +) -> Result<(), XpmDecodeError> { + let b = read_byte(r, part)?; + if !(b == b' ' || b == b'\t') { + return Err(XpmDecodeError::Parse(part, r.loc())); + } + while let Some(b) = r.peek() { + if b == b' ' || b == b'\t' { + r.next(); + continue; + } else { + return Ok(()); + } + } + Ok(()) +} + +/// Read a mixture of ' ', '\t', '\n', and C-style /* comments */. +/// This will error if it sees a / without following * +fn skip_whitespace_and_comments>( + r: &mut TextReader, + part: XpmPart, +) -> Result { + let mut nbytes = 0; + + // `has_first_char`: If out of comment, has / ; if in comment, has * + let mut has_first_char = false; + let mut in_comment = false; + + while let Some(b) = r.peek() { + if !in_comment { + if has_first_char { + if b != b'*' { + return Err(XpmDecodeError::Parse(part, r.loc())); + } else { + in_comment = true; + has_first_char = false; + } + } + if b == b'/' { + has_first_char = true; + } + } + if b == b' ' || b == b'\t' || b == b'\n' || b == b'/' || in_comment { + if in_comment { + if has_first_char && b == b'/' { + in_comment = false; + } + has_first_char = b == b'*'; + } + nbytes += 1; + r.next(); + continue; + } else { + break; + } + } + if !in_comment && has_first_char { + // Parsed up to a / but did not find * + return Err(XpmDecodeError::Parse(part, r.loc())); + } + + Ok(nbytes) +} + +fn skip_spaces_and_tabs>( + r: &mut TextReader, +) -> Result { + let mut nbytes = 0; + while let Some(b) = r.peek() { + if b == b' ' || b == b'\t' { + nbytes += 1; + r.next(); + continue; + } else { + break; + } + } + Ok(nbytes) +} + +/// Read a mixture of ' ' and '\t', until reading '\n'. +fn read_to_newline>( + r: &mut TextReader, + part: XpmPart, +) -> Result<(), XpmDecodeError> { + while let Some(b) = r.peek() { + if b == b' ' || b == b'\t' { + r.next(); + continue; + } else { + break; + } + } + if read_byte(r, part)? != b'\n' { + Err(XpmDecodeError::Parse(part, r.loc())) + } else { + Ok(()) + } +} +/// Read token into the buffer until the buffer size is exceeded, or ' ' or '\t' or '"' is found +/// \ characters are forbidden. Returns the region of data read. +fn read_until_whitespace_or_eos<'a, R: Iterator>( + r: &mut TextReader, + buf: &'a mut [u8], + part: XpmPart, +) -> Result<&'a mut [u8], XpmDecodeError> { + let mut len = 0; + while let Some(b) = r.peek() { + if b == b' ' || b == b'\t' || b == b'"' { + return Ok(&mut buf[..len]); + } else if b == b'\\' { + r.next(); + return Err(XpmDecodeError::Parse(part, r.loc())); + } else { + if len >= buf.len() { + // identifier is too long + return Err(XpmDecodeError::Parse(part, r.loc())); + } + buf[len] = b; + len += 1; + r.next(); + } + } + Ok(&mut buf[..len]) +} + +/// Read fixed length token into the buffer. Errors if file ends, or " or \ is found. +fn read_all_except_eos>( + r: &mut TextReader, + buf: &mut [u8], + part: XpmPart, +) -> Result<(), XpmDecodeError> { + let mut len = 0; + while let Some(b) = r.peek() { + if b == b'"' || b == b'\\' { + r.next(); + return Err(XpmDecodeError::Parse(part, r.loc())); + } else { + buf[len] = b; + len += 1; + r.next(); + if len >= buf.len() { + return Ok(()); + } + } + } + Err(XpmDecodeError::Parse(part, r.loc())) +} + +/// Read the name portion of the file (but do not validate it, because some old files +/// may put invalid characters here (like "." and "-") or use 8-bit character sets instead +/// of Unicode.) +fn read_name>( + r: &mut TextReader, + part: XpmPart, +) -> Result<(), XpmDecodeError> { + let mut empty = true; + while let Some(b) = r.peek() { + match b { + b'/' | b' ' | b'\t' | b'\n' | b'[' => { + break; + } + _ => (), + } + r.next(); + empty = false; + } + if empty { + return Err(XpmDecodeError::Parse(part, r.loc())); + } + + Ok(()) +} + +/// Parse string into integer, rejecting leading + and leading zeros +fn parse_i32(data: &[u8]) -> Option { + if data.starts_with(b"-") { + (-(parse_u32(&data[1..])? as i64)).try_into().ok() + } else { + parse_u32(data)?.try_into().ok() + } +} + +/// Parse string into unsigned integer, rejecting leading + and leading zeros +fn parse_u32(data: &[u8]) -> Option { + let Some(c1) = data.first() else { + // Reject empty string + return None; + }; + if *c1 == b'0' && data.len() > 1 { + // Reject leading zeros unless value is exactly zero + return None; + } + let mut x: u32 = 0; + for c in data { + if b'0' <= *c && *c <= b'9' { + x = x.checked_mul(10)?.checked_add((*c - b'0') as u32)?; + } else { + return None; + } + } + Some(x) +} +fn parse_hex(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'A'..=b'F' => Some(b - b'A' + 10), + b'a'..=b'f' => Some(b - b'a' + 10), + _ => None, + } +} +fn parse_hex1(x1: u8) -> Option { + let x = parse_hex(x1)? as u16; + Some(x | (x << 4) | (x << 8) | (x << 12)) +} +fn parse_hex2(x2: u8, x1: u8) -> Option { + let x = ((parse_hex(x2)? as u16) << 4) | (parse_hex(x1)? as u16); + Some(x | (x << 8)) +} +fn parse_hex3(x3: u8, x2: u8, x1: u8) -> Option { + let x = + ((parse_hex(x3)? as u16) << 8) | ((parse_hex(x2)? as u16) << 4) | (parse_hex(x1)? as u16); + // There are four reasonable approaches to converting 12-bit to 16-bit, + // round down, round nearest, round up, and round fast + // (x*65535)/4095, (x*65535+2047)/4095, (x*65535+4094)/4095, and (x<<4)|(x>>8). + Some((((x as u32) * 65535 + 2047) / 4095) as u16) +} +fn parse_hex4(x4: u8, x3: u8, x2: u8, x1: u8) -> Option { + Some( + (parse_hex(x1)? as u16) + | ((parse_hex(x2)? as u16) << 4) + | ((parse_hex(x3)? as u16) << 8) + | ((parse_hex(x4)? as u16) << 12), + ) +} +fn scale_u8_to_u16(x: u8) -> u16 { + (x as u16) << 8 | (x as u16) +} + +/// Parse an #RGB-style color. +/// Note: this deviates from XParseColor in order to sensibly interpret #aabbcc as #aaaabbbbcccc +/// instead of #aa00bb00cc00. +fn parse_hex_color(data: &[u8]) -> Option<[u16; 4]> { + Some(match data { + [r, g, b] => [parse_hex1(*r)?, parse_hex1(*g)?, parse_hex1(*b)?, 0xffff], + [r2, r1, g2, g1, b2, b1] => [ + parse_hex2(*r2, *r1)?, + parse_hex2(*g2, *g1)?, + parse_hex2(*b2, *b1)?, + 0xffff, + ], + [r3, r2, r1, g3, g2, g1, b3, b2, b1] => [ + parse_hex3(*r3, *r2, *r1)?, + parse_hex3(*g3, *g2, *g1)?, + parse_hex3(*b3, *b2, *b1)?, + 0xffff, + ], + [r4, r3, r2, r1, g4, g3, g2, g1, b4, b3, b2, b1] => [ + parse_hex4(*r4, *r3, *r2, *r1)?, + parse_hex4(*g4, *g3, *g2, *g1)?, + parse_hex4(*b4, *b3, *b2, *b1)?, + 0xffff, + ], + _ => { + return None; + } + }) +} + +fn parse_color(data: &[u8]) -> Result<[u16; 4], XpmDecodeError> { + if data.starts_with(b"#") { + parse_hex_color(&data[1..]).ok_or(XpmDecodeError::BadHexColor) + } else { + if data == b"none" { + return Ok([0, 0, 0, 0]); + } + + if let Ok(idx) = + image_x11r6colors::COLORS.binary_search_by(|entry| entry.0.as_bytes().cmp(data)) + { + let entry = image_x11r6colors::COLORS[idx]; + Ok([ + scale_u8_to_u16(entry.1), + scale_u8_to_u16(entry.2), + scale_u8_to_u16(entry.3), + 0xffff, + ]) + } else { + // At this point, `data` has been validated as alphanumeric ASCII; read_xpm_palette + // should ensure its length is <= MAX_COLOR_NAME_LEN + assert!(data.len() <= MAX_COLOR_NAME_LEN); + let mut tmp = [0u8; MAX_COLOR_NAME_LEN]; + tmp[..data.len()].copy_from_slice(data); + Err(XpmDecodeError::UnknownColor((tmp, data.len() as u8))) + } + } +} + +/// Read the header of the XPM image and first line +fn read_xpm_header>( + r: &mut TextReader, +) -> Result { + // Note: XPM3 header is `/* XPM */` + read_fixed_string(r, b"/* XPM */", XpmPart::Header)?; + read_to_newline(r, XpmPart::Header)?; + + skip_whitespace_and_comments(r, XpmPart::ArrayStart)?; + read_fixed_string(r, b"static", XpmPart::ArrayStart)?; + if skip_whitespace_and_comments(r, XpmPart::ArrayStart)? == 0 { + /* need a space or other char between 'static' and 'char' */ + return Err(XpmDecodeError::Parse(XpmPart::ArrayStart, r.loc())); + } + read_fixed_string(r, b"char", XpmPart::ArrayStart)?; + skip_whitespace_and_comments(r, XpmPart::ArrayStart)?; + read_fixed_string(r, b"*", XpmPart::ArrayStart)?; + skip_whitespace_and_comments(r, XpmPart::ArrayStart)?; + read_name(r, XpmPart::ArrayStart)?; + skip_whitespace_and_comments(r, XpmPart::ArrayStart)?; + read_fixed_string(r, b"[", XpmPart::ArrayStart)?; + skip_whitespace_and_comments(r, XpmPart::ArrayStart)?; + read_fixed_string(r, b"]", XpmPart::ArrayStart)?; + skip_whitespace_and_comments(r, XpmPart::ArrayStart)?; + read_fixed_string(r, b"=", XpmPart::ArrayStart)?; + skip_whitespace_and_comments(r, XpmPart::ArrayStart)?; + read_fixed_string(r, b"{", XpmPart::ArrayStart)?; + skip_whitespace_and_comments(r, XpmPart::ArrayStart)?; + + /* next: read \" */ + read_fixed_string(r, b"\"", XpmPart::FirstLine)?; + + // Inside strings, only spaces are allowed for separators + let mut int_buf = [0u8; 10]; // 2^32 fits in 10 bytes + skip_spaces_and_tabs(r)?; // words separated by space & tabulation chars -- so skip both? + let int = read_until_whitespace_or_eos(r, &mut int_buf, XpmPart::FirstLine)?; + let width = parse_u32(int).ok_or(XpmDecodeError::Parse(XpmPart::FirstLine, r.loc()))?; + if width == 0 { + return Err(XpmDecodeError::ZeroWidth); + } + + read_whitespace_gap(r, XpmPart::FirstLine)?; + let int = read_until_whitespace_or_eos(r, &mut int_buf, XpmPart::FirstLine)?; + let height = parse_u32(int).ok_or(XpmDecodeError::Parse(XpmPart::FirstLine, r.loc()))?; + if height == 0 { + return Err(XpmDecodeError::ZeroHeight); + } + + read_whitespace_gap(r, XpmPart::FirstLine)?; + let int = read_until_whitespace_or_eos(r, &mut int_buf, XpmPart::FirstLine)?; + let ncolors = parse_u32(int).ok_or(XpmDecodeError::Parse(XpmPart::FirstLine, r.loc()))?; + read_whitespace_gap(r, XpmPart::FirstLine)?; + let int = read_until_whitespace_or_eos(r, &mut int_buf, XpmPart::FirstLine)?; + let cpp = parse_u32(int).ok_or(XpmDecodeError::Parse(XpmPart::FirstLine, r.loc()))?; + skip_spaces_and_tabs(r)?; + + let _hotspot = if let Some(b'"') = r.peek() { + // Done + None + } else { + let int = read_until_whitespace_or_eos(r, &mut int_buf, XpmPart::FirstLine)?; + let hotspot_x = parse_i32(int).ok_or(XpmDecodeError::Parse(XpmPart::FirstLine, r.loc()))?; + read_whitespace_gap(r, XpmPart::FirstLine)?; + let int = read_until_whitespace_or_eos(r, &mut int_buf, XpmPart::FirstLine)?; + let hotspot_y = parse_i32(int).ok_or(XpmDecodeError::Parse(XpmPart::FirstLine, r.loc()))?; + skip_spaces_and_tabs(r)?; + + // Parse hotspot now. + Some((hotspot_x, hotspot_y)) + }; + // XPMEXT tags are not supported -- they were essentially never used in practice. + + read_fixed_string(r, b"\"", XpmPart::FirstLine)?; + skip_whitespace_and_comments(r, XpmPart::FirstLine)?; + read_fixed_string(r, b",", XpmPart::FirstLine)?; + skip_whitespace_and_comments(r, XpmPart::FirstLine)?; + + if ncolors == 0 { + return Err(XpmDecodeError::ZeroColors); + } + if cpp == 0 || cpp > 8 { + /* cpp larger than 8 is pointless and would not be made by sane encoders: + * with hex encoding, it would allow 2^32 distinct colors. */ + return Err(XpmDecodeError::BadCharsPerColor(cpp)); + } + + Ok(XpmHeaderInfo { + width, + height, + ncolors, + cpp, + }) +} +/// Read the palette portion of the XPM image, stopping just before the first pixel +fn read_xpm_palette>( + r: &mut TextReader, + info: &XpmHeaderInfo, +) -> Result { + assert!(1 <= info.cpp && info.cpp <= 8); + + // Check that color table is sorted + assert!(image_x11r6colors::COLORS + .windows(2) + .all(|p| p[0].0 < p[1].0)); + + // Even though the file provides a value for `ncolors`, and memory limits are validated, + // do NOT reserve the suggested memory in advance. Dynamically resizing the vector + // is negligibly slower, but ensures that the amount of memory allocated is always + // bounded by a multiple of the actual file size. Kernel virtual memory optimizations + // may hide the performance cost of allocating a 100MB color table from the + // application, but such allocations are still expensive even if mostly unused. + let mut color_table: Vec = Vec::new(); + + for _col in 0..info.ncolors { + read_fixed_string(r, b"\"", XpmPart::Palette)?; + + let mut code = [0_u8; 8]; + read_all_except_eos(r, &mut code[..info.cpp as usize], XpmPart::Palette)?; + read_whitespace_gap(r, XpmPart::Palette)?; + + // Color parsing: XPM color specifications have the form { }+ + // This is tricky to parse correctly as color names may contain spaces. + // Fortunately, the key values are "m", "s", "g4", "g", "c", which will + // never be a word within a color name, so one can acquire the entire color + // name by parsing until the next key appears or until '"' arrives. + + // Like the X server, this parser does a case-insensitive match on color names. + // Unfortunately, there is no general way to handle spaces in names: the color + // name database includes variants with spaces for multi-word names that do not + // end in a number; e.g. "antiquewhite" has a split variation "antique white", + // but "antiquewhite3" does not. + + let mut color_name_buf = [0_u8; MAX_COLOR_NAME_LEN]; + let mut color_name_len = 0; + let mut next_buf = [0_u8; MAX_COLOR_NAME_LEN]; + + let mut key: Option = None; + + let mut cvis_color = None; + loop { + if r.peek().unwrap_or(b'"') == b'"' { + let Some(ref k) = key else { + // At end of line, must have read a key + return Err(XpmDecodeError::MissingEntry); + }; + if color_name_len == 0 { + // At end of line, must also have read a color to process + return Err(XpmDecodeError::MissingColorAfterKey); + } + + let color = handle_key_color(k, &color_name_buf[..color_name_len])?; + cvis_color = color.or(cvis_color); + break; + } + + let next = read_until_whitespace_or_eos(r, &mut next_buf, XpmPart::Palette)?; + skip_spaces_and_tabs(r)?; + + let this_key = match &next[..] { + b"m" => Some(XpmVisual::Mono), + b"s" => Some(XpmVisual::Symbolic), + b"g4" => Some(XpmVisual::Grayscale4), + b"g" => Some(XpmVisual::Grayscale), + b"c" => Some(XpmVisual::Color), + _ => None, + }; + + let Some(ref k) = key else { + // No key has been set, is first key-color pair in the line + if this_key.is_none() { + // Error: processing non-key value with no preceding key + return Err(XpmDecodeError::MissingKeyBeforeColor); + }; + + key = this_key; + continue; + }; + + if this_key.is_some() { + // End of preceding segment + if color_name_len == 0 { + return Err(XpmDecodeError::TwoKeysInARow); + } + + let color = handle_key_color(k, &color_name_buf[..color_name_len])?; + cvis_color = color.or(cvis_color); + color_name_len = 0; + key = this_key; + continue; + } + + // Validate word, case fold it, and concatenate it with the preceding word, + // adding a space betweeen words + if color_name_len > 0 { + if color_name_len < MAX_COLOR_NAME_LEN { + color_name_buf[color_name_len] = b' '; + color_name_len += 1; + } else { + return Err(XpmDecodeError::ColorNameTooLong); + } + } + for c in next { + if !valid_name_char(*c) { + return Err(XpmDecodeError::InvalidColorName); + } + // Reduce to lowercase, matching the color name database, to + // make regular string comparisons be case-insensitive + if color_name_len < MAX_COLOR_NAME_LEN { + color_name_buf[color_name_len] = fold_to_lower(*c); + color_name_len += 1; + } else { + return Err(XpmDecodeError::ColorNameTooLong); + } + } + } + + let Some(color) = cvis_color else { + return Err(XpmDecodeError::NoColorModeColorSpecified); + }; + + color_table.push(XpmColorCodeEntry { + code: u64::from_le_bytes(code), + value: color, + }); + + read_fixed_string(r, b"\"", XpmPart::Palette)?; + skip_whitespace_and_comments(r, XpmPart::Palette)?; + read_fixed_string(r, b",", XpmPart::Palette)?; + skip_whitespace_and_comments(r, XpmPart::Palette)?; + } + + // Sort table and check for duplicates + color_table.sort_unstable_by(|x, y| x.code.cmp(&y.code)); + for w in color_table.windows(2) { + if w[0].code.cmp(&w[1].code) != Ordering::Less { + return Err(XpmDecodeError::DuplicateCode); + } + } + + read_fixed_string(r, b"\"", XpmPart::Body)?; + + Ok(XpmPalette { table: color_table }) +} +/// Read a single pixel from within the main image area +fn read_xpm_pixel>( + r: &mut TextReader, + info: &XpmHeaderInfo, + palette: &XpmPalette, + chunk: &mut [u8; 8], +) -> Result<(), XpmDecodeError> { + let mut code = [0_u8; 8]; + read_all_except_eos(r, &mut code[..info.cpp as usize], XpmPart::Palette)?; + let code = u64::from_le_bytes(code); + + let Ok(index) = palette + .table + .binary_search_by(|entry| entry.code.cmp(&code)) + else { + return Err(XpmDecodeError::UnknownCode); + }; + + let color = palette.table[index].value; + // ColorType::Rgba16 is currently native endian, R,G,B,A channel order + chunk[0..2].copy_from_slice(&color[0].to_ne_bytes()); + chunk[2..4].copy_from_slice(&color[1].to_ne_bytes()); + chunk[4..6].copy_from_slice(&color[2].to_ne_bytes()); + chunk[6..8].copy_from_slice(&color[3].to_ne_bytes()); + Ok(()) +} +/// Read the end of this row of the XPM image body and the start of the next. +/// Should only be called between rows, and not after the last one +fn read_xpm_row_transition>( + r: &mut TextReader, +) -> Result<(), XpmDecodeError> { + // End of this line + read_fixed_string(r, b"\"", XpmPart::Body)?; + + skip_whitespace_and_comments(r, XpmPart::Body)?; + read_fixed_string(r, b",", XpmPart::Body)?; + skip_whitespace_and_comments(r, XpmPart::Body)?; + // Start of next line + read_fixed_string(r, b"\"", XpmPart::Body)?; + Ok(()) +} +/// Read the end of the XPM image +fn read_xpm_trailing>(r: &mut TextReader) -> Result<(), XpmDecodeError> { + // Read end of last line + read_fixed_string(r, b"\"", XpmPart::Body)?; + + // Read optional comma, followed by final }; + skip_whitespace_and_comments(r, XpmPart::Trailing)?; + let next = read_byte(r, XpmPart::Trailing)?; + if next == b',' { + skip_whitespace_and_comments(r, XpmPart::Trailing)?; + read_fixed_string(r, b"}", XpmPart::Trailing)?; + } else if next != b'}' { + return Err(XpmDecodeError::Parse(XpmPart::Trailing, r.loc())); + } + skip_whitespace_and_comments(r, XpmPart::Trailing)?; + read_fixed_string(r, b";", XpmPart::Trailing)?; + + skip_whitespace_and_comments(r, XpmPart::AfterEnd)?; + if r.next().is_some() { + // File has unexpected trailing contents. + Err(XpmDecodeError::Parse(XpmPart::AfterEnd, r.loc())) + } else { + Ok(()) + } +} + +impl XpmDecoder +where + R: BufRead, +{ + /// Create a new [XpmDecoder]. + pub fn new(reader: R) -> Result, ImageError> { + let mut r = TextReader::new(IoAdapter { + reader: reader.bytes(), + error: None, + }); + + let info = read_xpm_header(&mut r).apply_after(&mut r.inner.error)?; + + Ok(XpmDecoder { r, info }) + } +} + +/// Parse color, returning it if the key is also XpmVisual::Color +fn handle_key_color(key: &XpmVisual, color: &[u8]) -> Result, XpmDecodeError> { + if matches!(key, XpmVisual::Symbolic) { + return Ok(None); + } + let color = parse_color(color)?; + if matches!(key, XpmVisual::Color) { + Ok(Some(color)) + } else { + Ok(None) + } +} + +impl ImageDecoder for XpmDecoder { + fn dimensions(&self) -> (u32, u32) { + (self.info.width, self.info.height) + } + fn color_type(&self) -> ColorType { + // note: some images specify 16-bpc colors, and fully transparent pixels are possible, + // so RGBA16 is needed to handle all possible cases + ColorType::Rgba16 + } + fn read_image(mut self, buf: &mut [u8]) -> ImageResult<()> + where + Self: Sized, + { + assert!(1 <= self.info.cpp && self.info.cpp <= 8); + + let palette = + read_xpm_palette(&mut self.r, &self.info).apply_after(&mut self.r.inner.error)?; + + // Read main image contents + let stride = (self.info.width as usize).checked_mul(8).unwrap(); + for (i, row) in buf.chunks_exact_mut(stride).enumerate() { + for chunk in row.chunks_exact_mut(8) { + read_xpm_pixel(&mut self.r, &self.info, &palette, chunk.try_into().unwrap()) + .apply_after(&mut self.r.inner.error)?; + } + + if i >= (self.info.height - 1) as usize { + // Last row, + } else { + read_xpm_row_transition(&mut self.r).apply_after(&mut self.r.inner.error)?; + } + } + + read_xpm_trailing(&mut self.r).apply_after(&mut self.r.inner.error)?; + + Ok(()) + } + fn read_image_boxed(self: Box, buf: &mut [u8]) -> ImageResult<()> { + (*self).read_image(buf) + } + + fn set_limits(&mut self, limits: Limits) -> ImageResult<()> { + limits.check_support(&LimitSupport::default())?; + let (width, height) = self.dimensions(); + limits.check_dimensions(width, height)?; + + let max_pixels = u64::from(self.info.width) * u64::from(self.info.height); + let max_image_bytes = + max_pixels + .checked_mul(8) + .ok_or(ImageError::Limits(LimitError::from_kind( + LimitErrorKind::DimensionError, + )))?; + + let max_table_bytes = (self.info.ncolors as u64) * (size_of::() as u64); + let max_bytes = max_image_bytes + .checked_add(max_table_bytes) + .ok_or(ImageError::Limits(LimitError::from_kind( + LimitErrorKind::InsufficientMemory, + )))?; + + let max_alloc = limits.max_alloc.unwrap_or(u64::MAX); + if max_alloc < max_bytes { + return Err(ImageError::Limits(LimitError::from_kind( + LimitErrorKind::InsufficientMemory, + ))); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn image_missing_body() { + let data = b"/* XPM */ +static char *test[] = { +\"20 5 10 1\", +}; +"; + let decoder = XpmDecoder::new(&data[..]).unwrap(); + let mut image = vec![0; decoder.total_bytes() as usize]; + assert!(decoder.read_image(&mut image).is_err()); + } + + #[test] + fn invalid_color_name() { + let data = b"/* XPM */ +static char *test[] = { + \"1 1 1 1\", + \" c Antique White1\", + \" \", +};"; + let decoder = XpmDecoder::new(&data[..]).unwrap(); + let mut image = vec![0; decoder.total_bytes() as usize]; + assert!(decoder.read_image(&mut image).is_err()); + } + + #[test] + fn trailing_semicolon_required() { + let data = b"/* XPM */ + static char *test[] = { + \"1 1 1 1\", + \" c none\", + \" \", + };"; + let decoder = XpmDecoder::new(&data[..data.len() - 1]).unwrap(); + let mut image = vec![0; decoder.total_bytes() as usize]; + assert!(decoder.read_image(&mut image).is_err()); + + let decoder = XpmDecoder::new(&data[..]).unwrap(); + let mut image = vec![0; decoder.total_bytes() as usize]; + assert!(decoder.read_image(&mut image).is_ok()); + } +} diff --git a/tests/images/xpm/1x1.png b/tests/images/xpm/1x1.png new file mode 100644 index 0000000000000000000000000000000000000000..ee9e279c144f696e96059a0d74e20ec92dba030d GIT binary patch literal 77 zcmeAS@N?(olHy`uVBq!ia0vp^j36w)1|FVdQ&MBb@0Hl*I7XSbN literal 0 HcmV?d00001 diff --git a/tests/images/xpm/gray.xpm b/tests/images/xpm/gray.xpm new file mode 100644 index 0000000..99c22fd --- /dev/null +++ b/tests/images/xpm/gray.xpm @@ -0,0 +1,10 @@ +/* XPM */ +static char *dummy[] = { +" 6 1 6 8 ", +"0xxxxxxx c LIGHTslateGRAY", +"1xxxxxxx c light slate gray", +"2xxxxxxx c #789", +"3xxxxxxx c #778899", +"4xxxxxxx c #777888999", +"5xxxxxxx c #777788889999", +"0xxxxxxx1xxxxxxx2xxxxxxx3xxxxxxx4xxxxxxx5xxxxxxx"}; diff --git a/tests/images/xpm/green.png b/tests/images/xpm/green.png new file mode 100644 index 0000000000000000000000000000000000000000..9e2d25ff1cdcc3fb97bbeef4a3230e4341e03154 GIT binary patch literal 234 zcmeAS@N?(olHy`uVBq!ia0vp^tRO7F1|;wP|FsNAZS-_;45?szdw_X?O60U-AODxX z>pgL1d69SWy4>5_peVCv-ZZcE^Nfs@-u*1wo>jH1-8%cK{sFe%o9%D^dvktU{TKJQ z=YQ!y)LYyA-u!U0y)=}P`))De!SOmCAa|$y#~a6+c<2AU`Qhe#7}Z}b_-)78&qol`;+0G0G{ A2LJ#7 literal 0 HcmV?d00001 diff --git a/tests/images/xpm/green.xpm b/tests/images/xpm/green.xpm new file mode 100644 index 0000000..58da447 --- /dev/null +++ b/tests/images/xpm/green.xpm @@ -0,0 +1,15 @@ +/* XPM */ +static char *vert_pâle[] = { +/* columns rows colors chars-per-pixel */ +"5 5 4 1 ", +" c green", +". c #AAFF7F", +"X c #80FF80", +"o c white", +/* pixels */ +" X.oo", +"X X.o", +".X X.", +"..X X", +"....." +}; diff --git a/tests/images/xpm/snake.png b/tests/images/xpm/snake.png new file mode 100644 index 0000000000000000000000000000000000000000..782bdc420660b93740d940695569798838aa2f1a GIT binary patch literal 336 zcmeAS@N?(olHy`uVBq!ia0vp^B0$V4zy>5mCkqrXFffXHx;TbZFupy&yg(&#+Od!S z%ir~$IJ3OSJ9%C1?QKw$*)wmN*ZO%z#!BygmTk|fTGnozeRcgOAF~EsMvpxV-_q;N zZrF&w+-xtcGOzpZ&H21$=UsojInUN9-p9ZoZTEX~rP%EBP4>SxAKV5a_EmhGd;h0; zz0i{L|8gc&2zt){dsE>f(+wb%@Oci$@6GnJPXjeEz5zN`%tU8%|rG2OV8pH>c6EopZ)AU6{y1zYzN%mdK II;Vst0B0