diff --git a/crates/engine/src/cmd/fileio/tests_aiimport.rs b/crates/engine/src/cmd/fileio/tests_aiimport.rs index 500576d4b..a420de0f9 100644 --- a/crates/engine/src/cmd/fileio/tests_aiimport.rs +++ b/crates/engine/src/cmd/fileio/tests_aiimport.rs @@ -82,6 +82,27 @@ fn an_illustrator_ai_file_opens_from_its_editing_data() { assert_ne!(names(&s.doc().unwrap().doc.layers), ["Art", "Hidden"]); } +/// A 1-bit RGB image opens from the editing data in the colors its PDF part shows. +#[test] +fn a_one_bit_rgb_image_opens_from_the_editing_data_in_its_colors() { + // 3 × 1: red, green, blue (100 010 001), as hex samples in both parts. + let raster = "%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/DeviceRGB XN\n[ 60 0 0 60 10 90 ] 3 1 0 Xh\n\ + [ 60 0 0 60 10 90 ] 0 0 3 1 3 1 1 3 0 0 0 0 4 4 0 0\n%%BeginData: 6\rXI\n8880\n%%EndData\r\nXH\r\n%AI5_EndRaster\r\nN\r\n"; + let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", raster)); + let page = "q 180 0 0 60 10 30 cm BI /W 3 /H 1 /CS /RGB /BPC 1 /F /AHx ID 8880> EI Q"; + let file = ai::ai(&compressed(data.as_bytes()), page); + let mut s = Session::new(); + let r = open(&mut s, "bits.ai", &file, json!({})).unwrap(); + let doc = &s.doc().unwrap().doc; + assert_eq!(names(&doc.layers), ["Art"], "{r}"); + let mut found = vec![]; + doc.visit_images(|_, im| { + let img = image::load_from_memory(&doc.images[&im.key].bytes).unwrap().to_rgb8(); + found.push(img.pixels().map(|p| p.0).collect::>()); + }); + assert_eq!(found, [[[255, 0, 0], [0, 255, 0], [0, 0, 255]]], "{r}"); +} + /// A PDF saved with the editing data opens from it, as a `.ai` does (#868). #[test] fn a_pdf_saved_with_the_editing_data_opens_from_it() { diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 7e0ea85f8..6c44473e5 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -2239,6 +2239,7 @@ fn arb_ai_token() -> impl Strategy { "/DeviceCMYK XN", "/DeviceGray XN", "[ 1 0 0 1 0 0 ] 0 0 2 2 2 2 8 3 1 0 1 0", + "[ 1 0 0 1 0 0 ] 0 0 3 2 3 2 1 4 1 0 1 0", "[ 1 0 0 1 0 0 ] 0 0 99999 99999 99999 99999 1 1 0 0 0 0", "[", "]", diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index e4ed195b1..37bb56240 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -1841,23 +1841,22 @@ impl<'a> Reader<'a> { for y in 0..h as usize { let line = samples.get(y * row..(y + 1) * row).unwrap_or_default(); for x in 0..w as usize { - let px = if *bits == 1.0 { - let bit = line.get(x / 8).map_or(0, |b| (b >> (7 - x % 8)) & 1); - let v = if bit == 1 { 255 } else { 0 }; - [v, v, v] - } else { - let s = |c: usize| line.get(x * channels + c).copied().unwrap_or(0); - match channels { - 1 => [s(0); 3], - 4 => { - let key = [s(0), s(1), s(2), s(3)]; - if self.cmyk_cache.len() > 1 << 16 { - self.cmyk_cache.clear(); - } - *self.cmyk_cache.entry(key).or_insert_with(|| cmyk_rgb(key)) + // A 1-bit row holds each pixel's channels in turn, a bit each, padded to a whole + // byte (`row`); a set bit is the channel at full strength (255). + let s = |c: usize| { + let i = x * channels + c; + if *bits == 1.0 { line.get(i / 8).map_or(0, |b| ((b >> (7 - i % 8)) & 1) * 255) } else { line.get(i).copied().unwrap_or(0) } + }; + let px = match channels { + 1 => [s(0); 3], + 4 => { + let key = [s(0), s(1), s(2), s(3)]; + if self.cmyk_cache.len() > 1 << 16 { + self.cmyk_cache.clear(); } - _ => [s(0), s(1), s(2)], + *self.cmyk_cache.entry(key).or_insert_with(|| cmyk_rgb(key)) } + _ => [s(0), s(1), s(2)], }; let a = opacity.and_then(|o| o.get(y * w as usize + x)).copied().unwrap_or(255); rgba.extend_from_slice(&[px[0], px[1], px[2], a]); diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index a8bfc4f31..afc33b99e 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -584,6 +584,33 @@ fn pixels(doc: &Document) -> (image::RgbaImage, kurbo::Affine) { (image::load_from_memory(&blob.bytes).unwrap().to_rgba8(), im.xf) } +/// A 1-bit image's row holds each pixel's channels in turn, one bit each, padded to whole bytes; +/// a set bit is the channel at full strength. +#[test] +fn one_bit_images_read_each_channel() { + let one_bit = |src: Vec, kind: u32| { + let text = String::from_utf8(src).unwrap(); + text.replacen(&format!(" 8 {kind} 0 0 1 0 "), &format!(" 1 {kind} 0 0 0 0 "), 1).into_bytes() + }; + // 3 × 2 RGB in hex: red, green, blue (100 010 001), then white, black, yellow (111 000 110). + let doc = read(&one_bit(image("[ 1 0 0 1 0 100 ]", (3, 2), ("DeviceRGB", 3, 0), b"8880e300", 11), 3)).unwrap().doc; + let img = pixels(&doc).0; + let row = |y: u32| (0..3).map(|x| img.get_pixel(x, y).0).collect::>(); + assert_eq!(row(0), [[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255]]); + assert_eq!(row(1), [[255, 255, 255, 255], [0, 0, 0, 255], [255, 255, 0, 255]]); + // 2 × 1 CMYK: cyan, then black (1000 0001). + let doc = read(&one_bit(image("[ 1 0 0 1 0 100 ]", (2, 1), ("DeviceCMYK", 4, 0), b"81", 5), 4)).unwrap().doc; + let img = pixels(&doc).0; + for (x, ink) in [[1.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 1.0]].into_iter().enumerate() { + let [r, g, b, _] = Color::cmyk(ink[0], ink[1], ink[2], ink[3]).to_rgba8(1.0); + assert_eq!(img.get_pixel(x as u32, 0).0, [r, g, b, 255], "pixel {x}"); + } + // 3 × 1 gray: a bit a pixel (101). + let doc = read(&one_bit(image("[ 1 0 0 1 0 100 ]", (3, 1), ("DeviceGray", 1, 0), b"a0", 5), 1)).unwrap().doc; + let img = pixels(&doc).0; + assert_eq!((0..3).map(|x| img.get_pixel(x, 0).0[0]).collect::>(), [255, 0, 255]); +} + #[test] fn images_with_alpha_channels() { // RGB samples, then the alpha channel (not counted in the data's size).