diff --git a/crates/doc/src/cmyk.rs b/crates/doc/src/cmyk.rs index fd85c120..e4c73f28 100644 --- a/crates/doc/src/cmyk.rs +++ b/crates/doc/src/cmyk.rs @@ -136,38 +136,57 @@ fn tiff_inks(bytes: &[u8]) -> Option { Inks::new(w, h, data) } -/// A CMYK TIFF with an alpha channel (5 samples) as RGBA pixels, each colour converted by `rgb` -/// (ink amounts 0..1 → sRGB 0..1) and associated (premultiplied) alpha divided back out; `None` -/// for other images, planar ones and ones larger than [`MAX_CMYK_PIXELS`]. The image decoder -/// doesn't read these at all; the inks can't be kept with the transparency, so they show as RGB. -pub fn cmyka_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3]) -> Option { +/// A TIFF whose pixels have samples after their colour, which the image decoder doesn't read at +/// all, as RGBA pixels: CMYK with an alpha channel (5 samples), each colour converted by `rgb` (ink +/// amounts 0..1 → sRGB 0..1; the inks can't be kept with the transparency, so they show as RGB), +/// and grayscale with extra samples, of which the first is the alpha when the file says so (other +/// channels are left out). Associated (premultiplied) alpha is divided back out. `None` for other +/// images, planar ones and ones larger than [`MAX_CMYK_PIXELS`]. +pub fn extra_samples_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3]) -> Option { + use tiff::tags::Tag; let (kind, mut d) = tiff(bytes)?; let (w, h) = d.dimensions().ok()?; - let planar = d.find_tag_unsigned::(tiff::tags::Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1); - if !matches!(kind, tiff::ColorType::CMYKA(8 | 16)) || planar { + let planar = d.find_tag_unsigned::(Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1); + // 0: white is zero, 1: black is zero (the two grayscales). + let photometric = d.find_tag_unsigned::(Tag::PhotometricInterpretation).ok().flatten(); + // ExtraSamples 1: associated alpha (premultiplied colour), 2: unassociated alpha, 0: not alpha. + let extra = d.find_tag_unsigned_vec::(Tag::ExtraSamples).ok().flatten().and_then(|v| v.first().copied()); + // (samples in a pixel, how many of them are its colour, is the next one alpha?) + let (samples, colour, alpha) = match kind { + tiff::ColorType::CMYKA(8 | 16) => (5, 4, true), + tiff::ColorType::Multiband { bit_depth: 8 | 16, num_samples: n @ 2.. } if matches!(photometric, Some(0 | 1)) => { + (usize::from(n), 1, matches!(extra, Some(1 | 2))) + } + _ => return None, + }; + if planar { return None; } let n = usize::try_from(pixels(w, h)?).ok()?; - // ExtraSamples 1: associated alpha (premultiplied colour); 2 (or none stated): unassociated. - let associated = d.find_tag_unsigned_vec::(tiff::tags::Tag::ExtraSamples).ok().flatten().is_some_and(|v| v.first() == Some(&1)); let data: Vec = match d.read_image().ok()? { tiff::decoder::DecodingResult::U8(v) => v, tiff::decoder::DecodingResult::U16(v) => v.iter().map(|s| (s >> 8) as u8).collect(), _ => return None, }; - if data.len() != n * 5 { + if Some(data.len()) != n.checked_mul(samples) { return None; } let mut out = Vec::with_capacity(n * 4); - for p in data.as_chunks::<5>().0 { - let a = f32::from(p[4]) / 255.0; - let ink = |v: u8| { + for p in data.chunks_exact(samples) { + let opacity = if alpha { p.get(colour).copied().unwrap_or(u8::MAX) } else { u8::MAX }; + let a = f32::from(opacity) / 255.0; + let level = |v: u8| { let v = f32::from(v) / 255.0; - if associated && a > 0.0 { (v / a).min(1.0) } else { v } + if extra == Some(1) && a > 0.0 { (v / a).min(1.0) } else { v } + }; + let [r, g, b] = match *p { + [c, m, y, k, ..] if colour == 4 => rgb([level(c), level(m), level(y), level(k)]), + [v, ..] if photometric == Some(0) => [1.0 - level(v); 3], + [v, ..] => [level(v); 3], + [] => continue, }; - let [r, g, b] = rgb([ink(p[0]), ink(p[1]), ink(p[2]), ink(p[3])]); out.extend([r, g, b].map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)); - out.push(p[4]); + out.push(opacity); } image::RgbaImage::from_raw(w, h, out) } @@ -187,12 +206,44 @@ mod tests { img.write_data(&[255, 0, 0, 0, 255, 0, 0, 0, 128, 128]).unwrap(); let bytes = file.into_inner(); let naive = |[c, m, y, k]: [f32; 4]| [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)]; - let px = cmyka_tiff_rgba(&bytes, naive).expect("CMYKA reads"); + let px = extra_samples_tiff_rgba(&bytes, naive).expect("CMYKA reads"); assert_eq!(px.dimensions(), (2, 1)); assert_eq!(px.get_pixel(0, 0).0, [0, 255, 255, 255], "cyan, opaque"); assert_eq!(px.get_pixel(1, 0).0, [0, 0, 0, 128], "the premultiplied K divided back out: black at half alpha"); // Not CMYKA: not this reader's. - assert!(cmyka_tiff_rgba(&ImageBlob::cmyk_tiff(&inks()).unwrap().bytes, naive).is_none()); + assert!(extra_samples_tiff_rgba(&ImageBlob::cmyk_tiff(&inks()).unwrap().bytes, naive).is_none()); + } + + /// Two 8-bit samples a pixel: gray and one extra sample, which the file's `ExtraSamples` names. + struct GrayExtra8; + impl tiff::encoder::colortype::ColorType for GrayExtra8 { + type Inner = u8; + const TIFF_VALUE: tiff::tags::PhotometricInterpretation = tiff::tags::PhotometricInterpretation::BlackIsZero; + const BITS_PER_SAMPLE: &'static [u16] = &[8, 8]; + const SAMPLE_FORMAT: &'static [tiff::tags::SampleFormat] = &[tiff::tags::SampleFormat::Uint; 2]; + fn horizontal_predict(row: &[u8], result: &mut Vec) { + result.extend_from_slice(row); + } + } + + /// A grayscale TIFF with an alpha channel, or with a channel that isn't one (#1466): the image + /// decoder reads neither. + #[test] + fn a_grayscale_tiff_with_an_extra_channel_reads_as_rgba() { + let gray = |extra: u16| { + let mut file = Cursor::new(Vec::new()); + let mut enc = tiff::encoder::TiffEncoder::new(&mut file).unwrap(); + let mut img = enc.new_image::(3, 1).unwrap(); + img.encoder().write_tag(tiff::tags::Tag::ExtraSamples, &[extra][..]).unwrap(); + // White opaque, a gray of 64 at 80% alpha (premultiplied: 51), black with no alpha. + img.write_data(&[255, 255, 51, 204, 0, 0]).unwrap(); + let bytes = file.into_inner(); + assert!(image::load_from_memory(&bytes).is_err(), "the image decoder reads it now: this reader may go"); + extra_samples_tiff_rgba(&bytes, |_| [0.0; 3]).expect("gray with an extra sample reads").into_raw() + }; + assert_eq!(gray(1), [255, 255, 255, 255, 64, 64, 64, 204, 0, 0, 0, 0], "associated alpha, divided back out"); + assert_eq!(gray(2), [255, 255, 255, 255, 51, 51, 51, 204, 0, 0, 0, 0], "unassociated alpha"); + assert_eq!(gray(0), [255, 255, 255, 255, 51, 51, 51, 255, 0, 0, 0, 255], "a channel that isn't alpha: opaque"); } fn inks() -> Inks { diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 613aace1..64fde0e5 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -397,7 +397,8 @@ const MAX_RASTER_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } else /// Decode an image's header (and, for formats stored as PNG, its pixels). CMYK TIFFs are kept as /// they are, with their ink amounts ([`ImageBlob::cmyk`]); CMYK TIFFs with an alpha channel, which /// the decoder can't read, become RGBA in the active colour settings' CMYK, as do CMYK Photoshop -/// documents, whose merged image is read ([`psdread`]). +/// documents, whose merged image is read ([`psdread`]). Grayscale TIFFs with an alpha channel or +/// other extra channels, which the decoder can't read either, become RGBA too. pub fn raster_image(bytes: &[u8]) -> Result { let ppi = super::ppi::resolution(bytes); if psdread::is_psd(bytes) { @@ -415,11 +416,11 @@ pub fn raster_image(bytes: &[u8]) -> Result { let (width, height) = reader.into_dimensions().map_err(err)?; return stored(bytes.to_vec(), f.mime, (width, height), ppi); } - let cmyka = || { + let extra_samples = || { let cms = vectorcraft_color::cms::active(); - vectorcraft_doc::cmyk::cmyka_tiff_rgba(bytes, |c| cms.cmyk_to_srgb(c, false)) + vectorcraft_doc::cmyk::extra_samples_tiff_rgba(bytes, |c| cms.cmyk_to_srgb(c, false)) }; - let img = match (f.id == "tiff").then(cmyka).flatten() { + let img = match (f.id == "tiff").then(extra_samples).flatten() { Some(img) => img, None => reader.decode().map_err(err)?.to_rgba8(), };