From ddae31bad4662005d2b04a6d85db4217b6173d1e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 07:55:06 -0800 Subject: [PATCH 1/8] TIFF: alpha after another extra sample places and opens as transparency A CMYK or grayscale TIFF reads its alpha from the first extra sample whose ExtraSamples value is 1 or 2, wherever it comes among the extra samples, at 8 and 16 bits, chunky or planar. TIFF 6.0 gives each extra sample its own ExtraSamples value, in order, and asks readers to skip extra samples they do not use. - A CMYK file with alpha after an unspecified extra sample was kept as opaque inks; it is now shown as RGB with its alpha. - A grayscale file with alpha after an unspecified extra sample opened opaque. - A six-sample CMYK file whose ExtraSamples tag lists only an unspecified value no longer reads that sample as transparency. - A white-is-zero grayscale file with an alpha channel opens; it failed to read before. --- crates/doc/src/cmyk.rs | 228 +++++++++++++++++++++++--- crates/engine/src/cmd/fileio/load.rs | 4 +- crates/engine/src/cmd/fileio/tests.rs | 32 ++++ docs/target-app-parity.md | 1 + 4 files changed, 239 insertions(+), 26 deletions(-) diff --git a/crates/doc/src/cmyk.rs b/crates/doc/src/cmyk.rs index 8cd3f85a4f..94f1017347 100644 --- a/crates/doc/src/cmyk.rs +++ b/crates/doc/src/cmyk.rs @@ -155,6 +155,61 @@ pub fn exif_orientation(bytes: &[u8]) -> Option { decoded().or_else(tagged).filter(|o| *o != Orientation::NoTransforms) } +/// TIFF `bytes` with the PhotometricInterpretation (tag 262) of their first image set to 1 (black +/// is zero) in each of its entries that holds one SHORT or LONG value. The decoder gives every +/// sample of a grayscale image's pixels, in the order the file stores them, where it gives a CMYK +/// image's inks and at most the first extra sample, and doesn't read a white-is-zero image with +/// extra samples. `None` for bytes that aren't a TIFF and when the first image has no such entry. +fn as_black_is_zero(bytes: &[u8]) -> Option> { + let le = bytes.starts_with(b"II*\0"); + if !le && !bytes.starts_with(b"MM\0*") { + return None; + } + let u16_at = |at: usize| -> Option { + let b: [u8; 2] = bytes.get(at..at.checked_add(2)?)?.try_into().ok()?; + Some(if le { u16::from_le_bytes(b) } else { u16::from_be_bytes(b) }) + }; + let u32_at = |at: usize| -> Option { + let b: [u8; 4] = bytes.get(at..at.checked_add(4)?)?.try_into().ok()?; + Some(if le { u32::from_le_bytes(b) } else { u32::from_be_bytes(b) }) + }; + let ifd = usize::try_from(u32_at(4)?).ok()?; + let mut out = bytes.to_vec(); + let mut changed = false; + for i in 0..usize::from(u16_at(ifd)?) { + let e = ifd.checked_add(i.checked_mul(12)?.checked_add(2)?)?; + let (Some(tag), Some(ty), Some(count)) = (u16_at(e), u16_at(e.checked_add(2)?), u32_at(e.checked_add(4)?)) else { break }; + if tag != 262 || count != 1 { + continue; + } + // The value sits in the entry, in the file's byte order. + let one: &[u8] = match (ty, le) { + (3, true) => &[1, 0], + (3, false) => &[0, 1], + (4, true) => &[1, 0, 0, 0], + (4, false) => &[0, 0, 0, 1], + _ => continue, + }; + let at = e.checked_add(8)?; + out.get_mut(at..at.checked_add(one.len())?)?.copy_from_slice(one); + changed = true; + } + changed.then_some(out) +} + +/// The ExtraSamples values of the image `d` reads, one for each sample after its color, in order: +/// 0 not alpha, 1 associated alpha, 2 unassociated alpha. Empty without the tag. +fn extra_samples(d: &mut tiff::decoder::Decoder>) -> Vec { + d.find_tag_unsigned_vec::(tiff::tags::Tag::ExtraSamples).ok().flatten().unwrap_or_default() +} + +/// Does `kind`, the color type of the TIFF `d` reads, hold inks that [`ImageBlob::cmyk`] reads: +/// CMYK of 8 or 16 bits, with no extra sample that the file names as alpha? The decoder gives a +/// CMYK color type to a file whose alpha follows another extra sample. +fn cmyk_inks(kind: tiff::ColorType, d: &mut tiff::decoder::Decoder>) -> bool { + matches!(kind, tiff::ColorType::CMYK(8 | 16)) && !extra_samples(d).iter().any(|e| matches!(e, 1 | 2)) +} + /// Does the TIFF `d` reads store one plane per channel (PlanarConfiguration 2)? fn planar(d: &mut tiff::decoder::Decoder>) -> bool { d.find_tag_unsigned::(tiff::tags::Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1) @@ -265,10 +320,11 @@ struct CmykSource { } /// The samples of CMYK TIFF `bytes` (8 or 16 bits), their inks together or one plane per ink, with -/// the TIFF's CMYK ICC profile; `None` for other images and ones larger than [`MAX_CMYK_PIXELS`]. +/// the TIFF's CMYK ICC profile; `None` for other images, CMYK ones with alpha and ones larger than +/// [`MAX_CMYK_PIXELS`]. fn tiff_source(bytes: &[u8]) -> Option { let (kind, mut d) = tiff(bytes)?; - if !matches!(kind, tiff::ColorType::CMYK(8 | 16)) { + if !cmyk_inks(kind, &mut d) { return None; } let (width, height) = d.dimensions().ok()?; @@ -358,13 +414,13 @@ impl ImageBlob { /// Does the image hold CMYK samples (a four-component JPEG or a CMYK TIFF)? Reads only its /// header. pub fn is_cmyk(&self) -> bool { - jpeg_components(&self.bytes) == Some(4) || tiff(&self.bytes).is_some_and(|(t, _)| matches!(t, tiff::ColorType::CMYK(8 | 16))) + jpeg_components(&self.bytes) == Some(4) || tiff(&self.bytes).is_some_and(|(t, mut d)| cmyk_inks(t, &mut d)) } /// Does the image hold CMYK samples of 16 bits (a CMYK TIFF), which [`Self::cmyk`] gives at 8 /// bits? Reads only its header. pub fn is_cmyk16(&self) -> bool { - tiff(&self.bytes).is_some_and(|(t, _)| matches!(t, tiff::ColorType::CMYK(16))) + tiff(&self.bytes).is_some_and(|(t, mut d)| matches!(t, tiff::ColorType::CMYK(16)) && cmyk_inks(t, &mut d)) } /// The width and height a JPEG or TIFF says it has, for any color type. Reads only its header. @@ -483,7 +539,7 @@ fn jpeg_inks(bytes: &[u8]) -> Option { fn tiff_inks(bytes: &[u8]) -> Option { let (kind, mut d) = tiff(bytes)?; let (w, h) = d.dimensions().ok()?; - if !matches!(kind, tiff::ColorType::CMYK(8 | 16)) { + if !cmyk_inks(kind, &mut d) { return None; } let n = usize::try_from(pixels(w, h)?).ok()?; @@ -527,31 +583,46 @@ pub fn turned_cmyk(bytes: &[u8], orientation: image::metadata::Orientation, ppi: } /// 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, and a pixel with -/// no associated alpha has no color. 16-bit samples are converted at 16 bits, and only the result -/// is rounded to 8 bits. The samples may be stored together or one plane per channel. `None` for -/// other images and ones larger than [`MAX_CMYK_PIXELS`]. +/// all, as RGBA pixels: CMYK with an alpha channel, each color 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. The alpha is the first extra sample whose ExtraSamples value +/// names it (1 or 2), whatever extra samples come before it; the other extra samples are left out, +/// and without such a value the pixels are opaque. A CMYK file with a fifth sample and no +/// ExtraSamples tag has that sample as unassociated alpha. Associated (premultiplied) alpha is +/// divided back out, and a pixel with no associated alpha has no color. 16-bit samples are +/// converted at 16 bits, and only the result is rounded to 8 bits. The samples may be stored +/// together or one plane per channel. `None` for other images, CMYK ones without alpha (their inks +/// are read by [`ImageBlob::cmyk`]) 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()?; // 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))) - } + let extras = extra_samples(&mut d); + // The first extra sample named as alpha: (its place among the extra samples, is it associated?) + let named = extras.iter().position(|e| matches!(e, 1 | 2)).map(|i| (i, extras.get(i) == Some(&1))); + // (how many of a pixel's samples are its color, the alpha among the extra samples) + let (colour, alpha) = match kind { + tiff::ColorType::CMYKA(8 | 16) => (4, if extras.is_empty() { Some((0, false)) } else { named }), + tiff::ColorType::CMYK(8 | 16) if named.is_some() => (4, named), + tiff::ColorType::Multiband { bit_depth: 8 | 16, num_samples: 2.. } if matches!(photometric, Some(0 | 1)) => (1, named), _ => return None, }; let n = usize::try_from(pixels(w, h)?).ok()?; - let data = read_samples_at_depth(&mut d, n, samples)?; + // The decoder gives a CMYK pixel its inks and at most one extra sample, and doesn't read + // white-is-zero gray with extra samples: those are read as black-is-zero gray, every sample. + let given = usize::from(kind.num_samples()); + let (data, samples) = if photometric == Some(0) || alpha.is_some_and(|(i, _)| colour + i >= given) { + let all = as_black_is_zero(bytes)?; + let (all_kind, mut d) = tiff(&all)?; + let samples = usize::from(all_kind.num_samples()); + (read_samples_at_depth(&mut d, n, samples)?, samples) + } else { + (read_samples_at_depth(&mut d, n, given)?, given) + }; + // (the alpha's sample in a pixel, is it associated?), when the pixel has that sample. + let alpha = alpha.map(|(i, associated)| (colour + i, associated)).filter(|(at, _)| *at < samples); // Sample `i` of the image, 0..1. let level_at = |i: usize| match &data { Samples::Eight(v) => v.get(i).map(|&s| f32::from(s) / 255.0), @@ -564,13 +635,16 @@ pub fn extra_samples_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3]) }; let mut out = Vec::with_capacity(n.checked_mul(4)?); for first in (0..n).map(|p| p * samples) { - let (opacity, a) = if alpha { (byte_at(first + colour).unwrap_or(u8::MAX), level_at(first + colour).unwrap_or(1.0)) } else { (u8::MAX, 1.0) }; + let (opacity, a) = match alpha { + Some((at, _)) => (byte_at(first + at).unwrap_or(u8::MAX), level_at(first + at).unwrap_or(1.0)), + None => (u8::MAX, 1.0), + }; let level = |s: usize| { let v = level_at(first + s).unwrap_or(0.0); - match extra { - Some(1) if a > 0.0 => (v / a).min(1.0), + match alpha { + Some((_, true)) if a > 0.0 => (v / a).min(1.0), // No associated alpha: the color is zero (TIFF 6.0, Associated Alpha Handling). - Some(1) if alpha => 0.0, + Some((_, true)) => 0.0, _ => v, } }; @@ -875,6 +949,110 @@ mod tests { assert_eq!(gray.into_raw(), [0, 0, 0, 0], "a gray of zero"); } + /// A pixel type of `$n` samples of `$inner`, `$bits` bits each, in color space `$space`. + macro_rules! samples { + ($name:ident, $inner:ty, $space:ident, $bits:literal, $n:literal) => { + struct $name; + impl tiff::encoder::colortype::ColorType for $name { + type Inner = $inner; + const TIFF_VALUE: tiff::tags::PhotometricInterpretation = tiff::tags::PhotometricInterpretation::$space; + const BITS_PER_SAMPLE: &'static [u16] = &[$bits; $n]; + const SAMPLE_FORMAT: &'static [tiff::tags::SampleFormat] = &[tiff::tags::SampleFormat::Uint; $n]; + fn horizontal_predict(row: &[$inner], result: &mut Vec<$inner>) { + result.extend_from_slice(row); + } + } + }; + } + samples!(Cmyk6x8, u8, CMYK, 8, 6); + samples!(Cmyk6x16, u16, CMYK, 16, 6); + samples!(Gray3x8, u8, BlackIsZero, 8, 3); + samples!(WhiteGray2x8, u8, WhiteIsZero, 8, 2); + + /// A one-row TIFF of `C`'s pixels `data`, with the ExtraSamples values `extra`. + fn extras_tiff(extra: &[u16], data: &[C::Inner]) -> Vec + where + [C::Inner]: tiff::encoder::TiffValue, + { + let samples = C::BITS_PER_SAMPLE.len(); + let mut file = Cursor::new(Vec::new()); + let mut enc = tiff::encoder::TiffEncoder::new(&mut file).unwrap(); + let mut img = enc.new_image::(u32::try_from(data.len() / samples).unwrap(), 1).unwrap(); + img.encoder().write_tag(tiff::tags::Tag::ExtraSamples, extra).unwrap(); + img.write_data(data).unwrap(); + file.into_inner() + } + + /// Two pixels of four inks with an alpha sample and another extra sample, the alpha first when + /// `alpha_first`: cyan at an alpha of 128 (or premultiplied, at 128 of cyan when `associated`) + /// with an extra sample of 9, then black, opaque, with an extra sample of 64. + fn inks_alpha_and_extra(alpha_first: bool, associated: bool) -> Vec { + let cyan = if associated { 128 } else { 255 }; + let px = |inks: [u16; 4], alpha: u16, other: u16| { + let extras = if alpha_first { [alpha, other] } else { [other, alpha] }; + [&inks[..], &extras[..]].concat() + }; + [px([cyan, 0, 0, 0], 128, 9), px([0, 0, 0, 255], 255, 64)].concat() + } + + /// A CMYK TIFF whose pixels have alpha and another extra sample reads the sample its + /// ExtraSamples names as alpha, first or second, at 8 and 16 bits, and leaves the other out. + #[test] + fn a_cmyk_tiff_with_alpha_and_another_extra_sample_reads_its_alpha() { + 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)]; + for extra in [[1, 0], [2, 0], [0, 1], [0, 2]] { + let data = inks_alpha_and_extra(extra[0] != 0, extra.contains(&1)); + let eight: Vec = data.iter().map(|&v| u8::try_from(v).unwrap()).collect(); + let sixteen: Vec = data.iter().map(|&v| v * 257).collect(); + for (bits, bytes) in [(8, extras_tiff::(&extra, &eight)), (16, extras_tiff::(&extra, &sixteen))] { + let px = extra_samples_tiff_rgba(&bytes, naive).unwrap_or_else(|| panic!("{extra:?}, {bits} bits: reads")); + assert_eq!(px.into_raw(), [0, 255, 255, 128, 0, 0, 0, 255], "{extra:?}, {bits} bits: cyan at half alpha, then black"); + assert!(stored_cmyk_tiff(&bytes, None).is_none(), "{extra:?}, {bits} bits: the inks can't be kept with the alpha"); + assert!(!ImageBlob::new("image/tiff", bytes).is_cmyk(), "{extra:?}, {bits} bits"); + } + } + // Two extra samples, neither of them alpha: the inks are kept, as with one. + let bytes = extras_tiff::(&[0, 0], &[255, 0, 0, 0, 9, 128, 0, 0, 0, 255, 64, 255]); + assert!(extra_samples_tiff_rgba(&bytes, naive).is_none(), "not alpha: not this reader's"); + assert!(stored_cmyk_tiff(&bytes, None).is_some()); + let b = ImageBlob::new("image/tiff", bytes); + assert!(b.is_cmyk() && !b.is_cmyk16()); + assert_eq!(b.cmyk().map(|i| i.data), Some(vec![255, 0, 0, 0, 0, 0, 0, 255])); + } + + /// A CMYK TIFF of six samples a pixel whose ExtraSamples has only one value: the sample after + /// the inks is alpha only when that value says so, and the sample after it is never alpha. + #[test] + fn a_cmyk_tiff_with_fewer_extra_sample_values_than_samples_reads_only_a_named_alpha() { + 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 data = [255, 0, 0, 0, 128, 9, 0, 0, 0, 255, 255, 64]; + let read = |extra: u16| extra_samples_tiff_rgba(&extras_tiff::(&[extra], &data), naive).expect("reads").into_raw(); + assert_eq!(read(2), [0, 255, 255, 128, 0, 0, 0, 255], "the sample after the inks is the alpha"); + assert_eq!(read(0), [0, 255, 255, 255, 0, 0, 0, 255], "not alpha: opaque"); + } + + /// A grayscale TIFF whose alpha follows another extra sample reads that alpha. + #[test] + fn a_grayscale_tiff_with_alpha_after_another_extra_sample_reads_its_alpha() { + let read = + |extra: [u16; 2], data: &[u8]| extra_samples_tiff_rgba(&extras_tiff::(&extra, data), |_| [0.0; 3]).expect("reads").into_raw(); + // A gray of 64 at an alpha of 204 (premultiplied: 51), with an extra sample of 9. + assert_eq!(read([0, 2], &[64, 9, 204]), [64, 64, 64, 204], "unassociated"); + assert_eq!(read([0, 1], &[51, 9, 204]), [64, 64, 64, 204], "associated, divided back out"); + assert_eq!(read([2, 0], &[64, 204, 9]), [64, 64, 64, 204], "the alpha first"); + assert_eq!(read([0, 0], &[64, 9, 204]), [64, 64, 64, 255], "no alpha: opaque"); + } + + /// A grayscale TIFF whose white is zero (PhotometricInterpretation 0), with alpha, reads with + /// its levels turned around. + #[test] + fn a_white_is_zero_grayscale_tiff_with_alpha_reads_as_rgba() { + // White, opaque; black at an alpha of 128. + let bytes = extras_tiff::(&[2], &[0, 255, 255, 128]); + let px = extra_samples_tiff_rgba(&bytes, |_| [0.0; 3]).expect("reads"); + assert_eq!(px.into_raw(), [255, 255, 255, 255, 0, 0, 0, 128]); + } + /// A header claiming more tiles than can be counted: (2^32 − 1)² pixels in tiles of one pixel, /// times 2 planes. #[test] diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index bb9dc3711a..b647fa9abf 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -850,7 +850,9 @@ pub(crate) fn size_note(bytes: &[u8]) -> Option { /// they are, with their ink amounts ([`ImageBlob::cmyk`]), except that ones storing one plane per /// ink are written again with their inks together ([`vectorcraft_doc::cmyk::stored_cmyk_tiff`]); /// CMYK TIFFs with an alpha channel, which the decoder can't read, become RGBA in the active color -/// settings' CMYK, as do CMYK Photoshop documents, whose merged image is read ([`psdread`]). +/// settings' CMYK, whichever of their extra channels is the alpha +/// ([`vectorcraft_doc::cmyk::extra_samples_tiff_rgba`]), as do CMYK Photoshop documents, whose +/// merged image is read ([`psdread`]). /// 1-bit TIFFs compressed with CCITT Group 3, which the decoder can't read either, become RGBA /// too ([`vectorcraft_doc::cmyk::group3_tiff_rgba`]). /// Grayscale TIFFs with an alpha channel or other extra channels, which the decoder can't read diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index 7374e26e2b..f7f39e5358 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -202,6 +202,38 @@ fn a_16_bit_cmyk_tiff_with_alpha_places_at_16_bits() { } } +/// A CMYK or grayscale TIFF whose alpha follows another extra sample opens and places with that +/// alpha, at 8 and 16 bits, chunky and planar; a CMYK one is not kept as inks. +#[test] +fn a_tiff_with_alpha_after_another_extra_sample_places_with_its_alpha() { + let none = vec![0u16; 6]; + let full = vec![255u16; 6]; + let other = vec![9u16, 8, 7, 6, 5, 4]; + let alpha = vec![255u16, 128, 0, 255, 64, 1]; + let cases = [ + ("CMYK", 5, vec![full.clone(), none.clone(), none.clone(), none.clone(), other.clone(), alpha.clone()]), + ("gray", 1, vec![full.clone(), other.clone(), alpha.clone()]), + ]; + for (what, photometric, channels) in cases { + for bits in [8, 16] { + let wide: Vec> = channels.iter().map(|ch| ch.iter().map(|v| if bits == 16 { v * 257 } else { *v }).collect()).collect(); + for planar in [false, true] { + let what = format!("{what}, {bits} bits, planar {planar}"); + let file = hand_tiff(photometric, bits, &[0, 2], planar, &wide); + let (inks, rgba) = kept(&what, &file); + assert_eq!(inks, None, "{what}: shown as RGB with its alpha"); + let alphas: Vec = rgba.iter().skip(3).step_by(4).copied().collect(); + assert_eq!(alphas, [255, 128, 0, 255, 64, 1], "{what}: the alpha is the second extra sample"); + let mut s = session(100.0, 100.0, 1); + s.execute("file.place", &json!({"name": "a.tif", "dataBase64": vectorcraft_format::base64_encode(&file)})).unwrap(); + let blob = &s.doc().unwrap().doc.images[&image_of(&s).key]; + let placed = image::load_from_memory(&blob.bytes).unwrap().to_rgba8().into_raw(); + assert_eq!(placed, rgba, "{what}: Place reads it as Open does"); + } + } + } +} + /// A planar TIFF whose header claims more strips than the decoder can count (here 2^31 + 1 rows /// a strip each, times 5 planes) is refused as too large to draw, without a panic. #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9ba8866a8c..4ae7bd7bc3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1783,6 +1783,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; grayscale TIFFs with an alpha channel or other extra channels place, relink and open, the alpha as the image's transparency (#1466 in part); PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546). - TIFFs that store each channel as its own plane (PlanarConfiguration 2) place, relink and open as the same image stored pixel by pixel does, 8 and 16 bits: grayscale with an alpha channel or other extra channels, and CMYK with or without them. A planar CMYK TIFF keeps its ink amounts and is stored as a CMYK TIFF with each pixel's inks together, at the file's resolution. A TIFF whose header claims more strips or tiles than can be counted is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`). - A 16-bit CMYK or grayscale TIFF with an alpha channel is converted from its 16-bit samples, and only the resulting color and alpha are rounded to 8 bits; associated alpha is divided out at 16 bits, and a pixel whose associated alpha is zero has no color. [TIFF 6.0](https://printtechnologies.org/standards/files/tiff-v6.pdf) gives extra samples the depth in BitsPerSample, and its Associated Alpha Handling section states the arithmetic and the zero-alpha rule for RGB data; applying them to CMYK and gray samples is VectorCraft's own rule. Each sample was cut to its high byte first, so a faint ink or a low alpha could be lost (`crates/doc/src/cmyk.rs`, `crates/engine/src/cmd/fileio/tests.rs`). + - A CMYK or grayscale TIFF whose alpha channel comes after another extra channel places and opens with that alpha, 8 and 16 bits, chunky or planar, and the other extra channels are left out. A channel that the file does not mark as alpha is not read as transparency, even when its ExtraSamples tag has fewer values than the file has extra channels, and a white-is-zero grayscale TIFF with an alpha channel opens too. [TIFF 6.0](https://printtechnologies.org/standards/files/tiff-v6.pdf) gives each extra channel its own ExtraSamples value, in order (pp. 31–32), and tells readers to skip extra channels they do not use (p. 27); reading the first channel marked as alpha is VectorCraft's own rule (`crates/doc/src/cmyk.rs`, `crates/engine/src/cmd/fileio/tests.rs`). - 1-bit TIFFs compressed with CCITT Group 3 (ITU-T T.4) place, relink and open: Compression 2 (modified Huffman) and Compression 3 with one-dimensional rows or, with T4Options bit 0, two-dimensional ones, with or without fill bits, in strips or tiles, with either FillOrder and either polarity. Rows in T.4's uncompressed mode are not read. A file whose pixels would take more than the decoding allowance as RGBA is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`). - EPS and PostScript import keeps a CMYK image's ink amounts, as PDF import does: the samples of `image` and `colorimage` in a CMYK color space, and of a CMYK JPEG read through `DCTDecode`, come in as a CMYK TIFF, read through the image's Decode array, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask (`crates/eps/src/import/tests_images.rs`). Such images came in as RGB PNGs. - EPS and PostScript import reads image data through `CCITTFaxDecode`: Group 4 (ITU-T T.6) and Group 3 one-dimensional and mixed (ITU-T T.4) data, with the filter's `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1` parameters, decodes to 1-bit rows for `image` and `imagemask`. Damaged data keeps the rows decoded before it (damage in the first row is a PostScript `ioerror`), and parameters out of range or past the 256 MiB limit on decoded data are refused (`crates/eps/src/import/tests_images.rs`). Reading such a file stopped at an error naming the filter, and the file opened from a fallback, such as its preview or the art drawn before the image, or did not open. From f5312d795fa2405162c5aa2984c031b30369a041 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 09:34:42 -0800 Subject: [PATCH 2/8] TIFF: stored CMYK TIFFs with alpha keep their inks; wide and large files read A document saved with a CMYK TIFF whose alpha follows another extra sample keeps it as a CMYK image with its ink amounts, so EPS and PDF export write the same inks as before; Open and Place still read such a file with its alpha. - A CMYK file with two or more samples past its inks and its ExtraSamples values, which the decoder gives no color type, reads the alpha that one of those values names, and its Orientation tag is read. - A TIFF with extra samples whose samples take more than the decoder's 256 MB buffer reads, up to the decoding allowance. - A file read as black-is-zero gray is read in place instead of copied. --- crates/doc/src/cmyk.rs | 251 +++++++++++++++++++++----- crates/engine/src/cmd/fileio/tests.rs | 5 +- docs/target-app-parity.md | 2 +- 3 files changed, 214 insertions(+), 44 deletions(-) diff --git a/crates/doc/src/cmyk.rs b/crates/doc/src/cmyk.rs index 94f1017347..032fcce596 100644 --- a/crates/doc/src/cmyk.rs +++ b/crates/doc/src/cmyk.rs @@ -124,20 +124,24 @@ pub fn oversized_tiff(bytes: &[u8]) -> Option<(u32, u32)> { (tiles || strips).then_some((width, height)) } -/// The color type of TIFF `bytes` and its decoder; `None` for [`oversized_tiff`] images too. -pub(crate) fn tiff(bytes: &[u8]) -> Option<(tiff::ColorType, tiff::decoder::Decoder>)> { +/// The decoder of TIFF `bytes`' first image; `None` for other bytes and [`oversized_tiff`] images. +fn tiff_decoder(bytes: &[u8]) -> Option>> { if !is_tiff(bytes) || oversized_tiff(bytes).is_some() { return None; } - let mut d = tiff::decoder::Decoder::new(Cursor::new(bytes)).ok()?; + tiff::decoder::Decoder::new(Cursor::new(bytes)).ok() +} + +/// The color type of TIFF `bytes` and its decoder; `None` for [`oversized_tiff`] images too. +pub(crate) fn tiff(bytes: &[u8]) -> Option<(tiff::ColorType, tiff::decoder::Decoder>)> { + let mut d = tiff_decoder(bytes)?; Some((d.colortype().ok()?, d)) } /// The Orientation tag of TIFF `bytes`' first image, of any unsigned type; `None` for other images -/// and TIFFs without one. Reads only its header. +/// and TIFFs without one. Reads only its header, for any color type. pub fn tiff_orientation(bytes: &[u8]) -> Option { - let (_, mut d) = tiff(bytes)?; - d.find_tag_unsigned::(tiff::tags::Tag::Orientation).ok().flatten() + tiff_decoder(bytes)?.find_tag_unsigned::(tiff::tags::Tag::Orientation).ok().flatten() } /// The EXIF orientation of encoded image `bytes` when it turns or flips the pixels; `None` when @@ -155,12 +159,46 @@ pub fn exif_orientation(bytes: &[u8]) -> Option { decoded().or_else(tagged).filter(|o| *o != Orientation::NoTransforms) } -/// TIFF `bytes` with the PhotometricInterpretation (tag 262) of their first image set to 1 (black -/// is zero) in each of its entries that holds one SHORT or LONG value. The decoder gives every -/// sample of a grayscale image's pixels, in the order the file stores them, where it gives a CMYK -/// image's inks and at most the first extra sample, and doesn't read a white-is-zero image with -/// extra samples. `None` for bytes that aren't a TIFF and when the first image has no such entry. -fn as_black_is_zero(bytes: &[u8]) -> Option> { +/// TIFF bytes as a reader sees them with some of them replaced: each patch is an offset and the +/// bytes read there instead, at most four, in order of offset without overlaps. The file is not +/// copied. +struct Patched<'a> { + bytes: Cursor<&'a [u8]>, + patches: Vec<(usize, &'static [u8])>, +} + +impl std::io::Read for Patched<'_> { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let start = usize::try_from(self.bytes.position()).unwrap_or(usize::MAX); + let read = std::io::Read::read(&mut self.bytes, buf)?; + let got = buf.get_mut(..read).unwrap_or_default(); + let end = start.saturating_add(read); + // The patches are in order and apart, and each is at most four bytes long. + let first = self.patches.partition_point(|&(at, _)| at.saturating_add(4) <= start); + for &(at, with) in self.patches.get(first..).unwrap_or_default().iter().take_while(|&&(at, _)| at < end) { + for (k, &b) in with.iter().enumerate() { + if let Some(slot) = at.checked_add(k).and_then(|i| i.checked_sub(start)).and_then(|i| got.get_mut(i)) { + *slot = b; + } + } + } + Ok(read) + } +} + +impl std::io::Seek for Patched<'_> { + fn seek(&mut self, to: std::io::SeekFrom) -> std::io::Result { + std::io::Seek::seek(&mut self.bytes, to) + } +} + +/// TIFF `bytes` read with the PhotometricInterpretation (tag 262) of their first image set to 1 +/// (black is zero) in each of its entries that holds one SHORT or LONG value. The decoder gives +/// every sample of a grayscale image's pixels, in the order the file stores them, where it gives a +/// CMYK image's inks and at most the first extra sample, and doesn't read a white-is-zero image +/// with extra samples. `None` for bytes that aren't a TIFF and when the first image has no such +/// entry. +fn as_black_is_zero(bytes: &[u8]) -> Option> { let le = bytes.starts_with(b"II*\0"); if !le && !bytes.starts_with(b"MM\0*") { return None; @@ -174,8 +212,7 @@ fn as_black_is_zero(bytes: &[u8]) -> Option> { Some(if le { u32::from_le_bytes(b) } else { u32::from_be_bytes(b) }) }; let ifd = usize::try_from(u32_at(4)?).ok()?; - let mut out = bytes.to_vec(); - let mut changed = false; + let mut patches = Vec::new(); for i in 0..usize::from(u16_at(ifd)?) { let e = ifd.checked_add(i.checked_mul(12)?.checked_add(2)?)?; let (Some(tag), Some(ty), Some(count)) = (u16_at(e), u16_at(e.checked_add(2)?), u32_at(e.checked_add(4)?)) else { break }; @@ -183,7 +220,7 @@ fn as_black_is_zero(bytes: &[u8]) -> Option> { continue; } // The value sits in the entry, in the file's byte order. - let one: &[u8] = match (ty, le) { + let one: &'static [u8] = match (ty, le) { (3, true) => &[1, 0], (3, false) => &[0, 1], (4, true) => &[1, 0, 0, 0], @@ -191,10 +228,10 @@ fn as_black_is_zero(bytes: &[u8]) -> Option> { _ => continue, }; let at = e.checked_add(8)?; - out.get_mut(at..at.checked_add(one.len())?)?.copy_from_slice(one); - changed = true; + bytes.get(at..at.checked_add(one.len())?)?; + patches.push((at, one)); } - changed.then_some(out) + (!patches.is_empty()).then(|| Patched { bytes: Cursor::new(bytes), patches }) } /// The ExtraSamples values of the image `d` reads, one for each sample after its color, in order: @@ -203,13 +240,27 @@ fn extra_samples(d: &mut tiff::decoder::Decoder>) -> Vec { d.find_tag_unsigned_vec::(tiff::tags::Tag::ExtraSamples).ok().flatten().unwrap_or_default() } -/// Does `kind`, the color type of the TIFF `d` reads, hold inks that [`ImageBlob::cmyk`] reads: -/// CMYK of 8 or 16 bits, with no extra sample that the file names as alpha? The decoder gives a -/// CMYK color type to a file whose alpha follows another extra sample. +/// Does `kind`, the color type of the TIFF `d` reads, hold inks that Open and Place keep: CMYK of 8 +/// or 16 bits, with no extra sample that the file names as alpha? The decoder gives a CMYK color +/// type to a file whose alpha follows another extra sample. [`ImageBlob::cmyk`] reads the inks of +/// such a file all the same: a document saved with one as a CMYK image keeps it. fn cmyk_inks(kind: tiff::ColorType, d: &mut tiff::decoder::Decoder>) -> bool { matches!(kind, tiff::ColorType::CMYK(8 | 16)) && !extra_samples(d).iter().any(|e| matches!(e, 1 | 2)) } +/// `d` with room to decode `n` pixels of `per` samples of `bits` bits each, when they take more +/// than the decoder's own limit (256 MB of samples); `None` when they take more than +/// [`crate::MAX_RASTER_ALLOC`]. +fn with_room(d: tiff::decoder::Decoder, n: usize, per: usize, bits: u8) -> Option> { + let need = n.checked_mul(per)?.checked_mul(usize::from(bits.div_ceil(8)))?; + if u64::try_from(need).ok()? > crate::MAX_RASTER_ALLOC { + return None; + } + let mut limits = tiff::decoder::Limits::default(); + limits.decoding_buffer_size = limits.decoding_buffer_size.max(need); + Some(d.with_limits(limits)) +} + /// Does the TIFF `d` reads store one plane per channel (PlanarConfiguration 2)? fn planar(d: &mut tiff::decoder::Decoder>) -> bool { d.find_tag_unsigned::(tiff::tags::Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1) @@ -251,7 +302,7 @@ enum SampleSlice<'a> { } /// [`read_samples`], keeping 16-bit samples at 16 bits. -fn read_samples_at_depth(d: &mut tiff::decoder::Decoder>, n: usize, per: usize) -> Option { +fn read_samples_at_depth(d: &mut tiff::decoder::Decoder, n: usize, per: usize) -> Option { let mut buf = tiff::decoder::DecodingResult::U8(Vec::new()); let layout = d.read_image_to_buffer(&mut buf).ok()?; match buf { @@ -414,13 +465,13 @@ impl ImageBlob { /// Does the image hold CMYK samples (a four-component JPEG or a CMYK TIFF)? Reads only its /// header. pub fn is_cmyk(&self) -> bool { - jpeg_components(&self.bytes) == Some(4) || tiff(&self.bytes).is_some_and(|(t, mut d)| cmyk_inks(t, &mut d)) + jpeg_components(&self.bytes) == Some(4) || tiff(&self.bytes).is_some_and(|(t, _)| matches!(t, tiff::ColorType::CMYK(8 | 16))) } /// Does the image hold CMYK samples of 16 bits (a CMYK TIFF), which [`Self::cmyk`] gives at 8 /// bits? Reads only its header. pub fn is_cmyk16(&self) -> bool { - tiff(&self.bytes).is_some_and(|(t, mut d)| matches!(t, tiff::ColorType::CMYK(16)) && cmyk_inks(t, &mut d)) + tiff(&self.bytes).is_some_and(|(t, _)| matches!(t, tiff::ColorType::CMYK(16))) } /// The width and height a JPEG or TIFF says it has, for any color type. Reads only its header. @@ -539,7 +590,7 @@ fn jpeg_inks(bytes: &[u8]) -> Option { fn tiff_inks(bytes: &[u8]) -> Option { let (kind, mut d) = tiff(bytes)?; let (w, h) = d.dimensions().ok()?; - if !cmyk_inks(kind, &mut d) { + if !matches!(kind, tiff::ColorType::CMYK(8 | 16)) { return None; } let n = usize::try_from(pixels(w, h)?).ok()?; @@ -549,8 +600,8 @@ fn tiff_inks(bytes: &[u8]) -> Option { /// CMYK TIFF `bytes` that no orientation turns ([`turned_cmyk`] stores the others) as Open and /// Place store them: as they are, or, when they store one plane per ink, which the image decoder /// doesn't read, their inks together in a CMYK TIFF with the resolution `ppi`, at the 8 or 16 bits -/// the file has, carrying the file's CMYK ICC profile. `None` for other images and for CMYK TIFFs -/// whose inks [`ImageBlob::cmyk`] doesn't read. +/// the file has, carrying the file's CMYK ICC profile. `None` for other images, for CMYK TIFFs +/// with an extra sample named as alpha and for ones whose inks [`ImageBlob::cmyk`] doesn't read. pub fn stored_cmyk_tiff(bytes: &[u8], ppi: Option<(f64, f64)>) -> Option> { let source = tiff_source(bytes)?; let (_, mut d) = tiff(bytes)?; @@ -564,7 +615,8 @@ pub fn stored_cmyk_tiff(bytes: &[u8], ppi: Option<(f64, f64)>) -> Option /// inks turned and written again as a CMYK TIFF with the resolution `ppi` of the turned pixels, /// with the turned width and height. A TIFF keeps its 8 or 16 bits, a JPEG has 8, and both keep /// their CMYK ICC profile (a JPEG's put together from all its APP2 segments). `None` for other -/// images and for CMYK images whose inks [`ImageBlob::cmyk`] doesn't read. +/// images, for CMYK TIFFs with an extra sample named as alpha and for CMYK images whose inks +/// [`ImageBlob::cmyk`] doesn't read. pub fn turned_cmyk(bytes: &[u8], orientation: image::metadata::Orientation, ppi: Option<(f64, f64)>) -> Option<(Vec, (u32, u32))> { let CmykSource { width, height, samples, icc } = if bytes.starts_with(&[0xFF, 0xD8]) { jpeg_source(bytes) } else { tiff_source(bytes) }?; // Four inks to a pixel, turned as four channels of the same depth are. @@ -591,35 +643,43 @@ pub fn turned_cmyk(bytes: &[u8], orientation: image::metadata::Orientation, ppi: /// ExtraSamples tag has that sample as unassociated alpha. Associated (premultiplied) alpha is /// divided back out, and a pixel with no associated alpha has no color. 16-bit samples are /// converted at 16 bits, and only the result is rounded to 8 bits. The samples may be stored -/// together or one plane per channel. `None` for other images, CMYK ones without alpha (their inks -/// are read by [`ImageBlob::cmyk`]) and ones larger than [`MAX_CMYK_PIXELS`]. +/// together or one plane per channel. A CMYK file with two or more samples past its inks and its +/// ExtraSamples values is read when one of those values names an alpha. `None` for other images, +/// CMYK ones without alpha (their inks are read by [`ImageBlob::cmyk`]), ones larger than +/// [`MAX_CMYK_PIXELS`] and ones whose samples take more than [`crate::MAX_RASTER_ALLOC`]. 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 mut d = tiff_decoder(bytes)?; + // The decoder gives no color type to a CMYK file that has six or more samples besides the + // ones its ExtraSamples values count. + let kind = d.colortype().ok(); let (w, h) = d.dimensions().ok()?; - // 0: white is zero, 1: black is zero (the two grayscales). + // 0: white is zero, 1: black is zero (the two grayscales), 5: CMYK. let photometric = d.find_tag_unsigned::(Tag::PhotometricInterpretation).ok().flatten(); let extras = extra_samples(&mut d); // The first extra sample named as alpha: (its place among the extra samples, is it associated?) let named = extras.iter().position(|e| matches!(e, 1 | 2)).map(|i| (i, extras.get(i) == Some(&1))); + // The samples of a pixel that no ExtraSamples value counts. + let uncounted = d.find_tag_unsigned::(Tag::SamplesPerPixel).ok().flatten().and_then(|all| usize::from(all).checked_sub(extras.len())); // (how many of a pixel's samples are its color, the alpha among the extra samples) let (colour, alpha) = match kind { - tiff::ColorType::CMYKA(8 | 16) => (4, if extras.is_empty() { Some((0, false)) } else { named }), - tiff::ColorType::CMYK(8 | 16) if named.is_some() => (4, named), - tiff::ColorType::Multiband { bit_depth: 8 | 16, num_samples: 2.. } if matches!(photometric, Some(0 | 1)) => (1, named), + Some(tiff::ColorType::CMYKA(8 | 16)) => (4, if extras.is_empty() { Some((0, false)) } else { named }), + Some(tiff::ColorType::CMYK(8 | 16)) if named.is_some() => (4, named), + Some(tiff::ColorType::Multiband { bit_depth: 8 | 16, num_samples: 2.. }) if matches!(photometric, Some(0 | 1)) => (1, named), + None if photometric == Some(5) && named.is_some() && uncounted.is_some_and(|c| c > 5) => (4, named), _ => return None, }; let n = usize::try_from(pixels(w, h)?).ok()?; // The decoder gives a CMYK pixel its inks and at most one extra sample, and doesn't read // white-is-zero gray with extra samples: those are read as black-is-zero gray, every sample. - let given = usize::from(kind.num_samples()); + let given = kind.map_or(0, |k| usize::from(k.num_samples())); let (data, samples) = if photometric == Some(0) || alpha.is_some_and(|(i, _)| colour + i >= given) { - let all = as_black_is_zero(bytes)?; - let (all_kind, mut d) = tiff(&all)?; - let samples = usize::from(all_kind.num_samples()); - (read_samples_at_depth(&mut d, n, samples)?, samples) + let mut d = tiff::decoder::Decoder::new(as_black_is_zero(bytes)?).ok()?; + let tiff::ColorType::Multiband { bit_depth: bits @ (8 | 16), num_samples } = d.colortype().ok()? else { return None }; + let samples = usize::from(num_samples); + (read_samples_at_depth(&mut with_room(d, n, samples, bits)?, n, samples)?, samples) } else { - (read_samples_at_depth(&mut d, n, given)?, given) + (read_samples_at_depth(&mut with_room(d, n, given, kind?.bit_depth())?, n, given)?, given) }; // (the alpha's sample in a pixel, is it associated?), when the pixel has that sample. let alpha = alpha.map(|(i, associated)| (colour + i, associated)).filter(|(at, _)| *at < samples); @@ -966,6 +1026,8 @@ mod tests { } samples!(Cmyk6x8, u8, CMYK, 8, 6); samples!(Cmyk6x16, u16, CMYK, 16, 6); + samples!(Cmyk7x8, u8, CMYK, 8, 7); + samples!(Cmyk8x8, u8, CMYK, 8, 8); samples!(Gray3x8, u8, BlackIsZero, 8, 3); samples!(WhiteGray2x8, u8, WhiteIsZero, 8, 2); @@ -997,6 +1059,9 @@ mod tests { /// A CMYK TIFF whose pixels have alpha and another extra sample reads the sample its /// ExtraSamples names as alpha, first or second, at 8 and 16 bits, and leaves the other out. + /// Open and Place don't keep its inks. A blob that holds the file, as a document saved with it + /// as a CMYK image does, is a CMYK image with those inks when the alpha is the second extra + /// sample. #[test] fn a_cmyk_tiff_with_alpha_and_another_extra_sample_reads_its_alpha() { 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)]; @@ -1008,7 +1073,16 @@ mod tests { let px = extra_samples_tiff_rgba(&bytes, naive).unwrap_or_else(|| panic!("{extra:?}, {bits} bits: reads")); assert_eq!(px.into_raw(), [0, 255, 255, 128, 0, 0, 0, 255], "{extra:?}, {bits} bits: cyan at half alpha, then black"); assert!(stored_cmyk_tiff(&bytes, None).is_none(), "{extra:?}, {bits} bits: the inks can't be kept with the alpha"); - assert!(!ImageBlob::new("image/tiff", bytes).is_cmyk(), "{extra:?}, {bits} bits"); + let b = ImageBlob::new("image/tiff", bytes); + if extra[0] != 0 { + // The decoder gives these the color type of CMYK with alpha. + assert!(!b.is_cmyk() && b.cmyk().is_none(), "{extra:?}, {bits} bits: never stored as inks"); + continue; + } + assert!(b.is_cmyk(), "{extra:?}, {bits} bits: a stored one stays a CMYK image"); + assert_eq!(b.is_cmyk16(), bits == 16, "{extra:?}, {bits} bits"); + let cyan = u8::try_from(data[0]).unwrap(); + assert_eq!(b.cmyk().map(|i| i.data), Some(vec![cyan, 0, 0, 0, 0, 0, 0, 255]), "{extra:?}, {bits} bits: a stored one keeps its inks"); } } // Two extra samples, neither of them alpha: the inks are kept, as with one. @@ -1029,6 +1103,99 @@ mod tests { let read = |extra: u16| extra_samples_tiff_rgba(&extras_tiff::(&[extra], &data), naive).expect("reads").into_raw(); assert_eq!(read(2), [0, 255, 255, 128, 0, 0, 0, 255], "the sample after the inks is the alpha"); assert_eq!(read(0), [0, 255, 255, 255, 0, 0, 0, 255], "not alpha: opaque"); + // Eight samples with two values: the decoder gives the file no color type. The second + // value names the sixth sample as the alpha. + let wide = [255, 0, 0, 0, 9, 128, 17, 18]; + let px = extra_samples_tiff_rgba(&extras_tiff::(&[0, 2], &wide), naive).expect("reads"); + assert_eq!(px.into_raw(), [0, 255, 255, 128], "cyan at the alpha the second value names"); + assert!(extra_samples_tiff_rgba(&extras_tiff::(&[0, 0], &wide), naive).is_none(), "no value names an alpha: not read"); + // Seven samples with two values: the decoder reads it as CMYK with alpha. + let px = extra_samples_tiff_rgba(&extras_tiff::(&[0, 2], &wide[..7]), naive).expect("reads"); + assert_eq!(px.into_raw(), [0, 255, 255, 128], "seven samples: cyan at the alpha the second value names"); + // Its Orientation tag is read too. + let mut file = Cursor::new(Vec::new()); + let mut enc = tiff::encoder::TiffEncoder::new(&mut file).unwrap(); + let mut img = enc.new_image::(1, 1).unwrap(); + img.encoder().write_tag(tiff::tags::Tag::ExtraSamples, &[0u16, 2][..]).unwrap(); + img.encoder().write_tag(tiff::tags::Tag::Orientation, 6u16).unwrap(); + img.write_data(&wide).unwrap(); + let turned = file.into_inner(); + assert_eq!(tiff_orientation(&turned), Some(6)); + assert_eq!(exif_orientation(&turned), Some(image::metadata::Orientation::Rotate90)); + } + + /// A white-is-zero grayscale TIFF one pixel wide and `rows` tall, each pixel `samples` samples + /// of 8 bits: a level of 0, an unassociated alpha of 128, then zeros. Each row is a strip, and + /// every strip is the same bytes of the file. + fn tall_gray_tiff(rows: u32, samples: u16) -> Vec { + let at = |f: &Vec| u32::try_from(f.len()).unwrap(); + let mut f = b"II*\0\0\0\0\0".to_vec(); + let row = at(&f); + f.extend([0, 128]); + f.resize(f.len() + usize::from(samples) - 2, 0); + let offsets = at(&f); + f.extend((0..rows).flat_map(|_| row.to_le_bytes())); + let counts = at(&f); + f.extend((0..rows).flat_map(|_| u32::from(samples).to_le_bytes())); + let ifd = at(&f); + f[4..8].copy_from_slice(&ifd.to_le_bytes()); + // (tag, type (3 SHORT, 4 LONG), count, value or offset) + let entries = [ + (256u16, 4u16, 1, 1), + (257, 4, 1, rows), + (258, 3, 1, 8), + (259, 3, 1, 1), + (262, 3, 1, 0), + (273, 4, rows, offsets), + (277, 3, 1, u32::from(samples)), + (278, 4, 1, 1), + (279, 4, rows, counts), + (338, 3, 1, 2), + ]; + f.extend(u16::try_from(entries.len()).unwrap().to_le_bytes()); + for (tag, ty, count, value) in entries { + f.extend([tag.to_le_bytes(), ty.to_le_bytes()].concat()); + f.extend([count.to_le_bytes(), value.to_le_bytes()].concat()); + } + f.extend([0; 4]); + f + } + + /// A TIFF with extra samples whose samples take more than the decoder's own limit of 256 MB + /// reads: here 65,537 pixels of 4,096 samples, 4,096 bytes more than the limit. + #[test] + fn a_tiff_with_extra_samples_past_the_decoders_limit_reads() { + let (rows, samples) = (65_537, 4096); + assert!(u64::from(rows) * u64::from(samples) > 256 << 20); + let px = extra_samples_tiff_rgba(&tall_gray_tiff(rows, samples), |_| [0.0; 3]).expect("reads"); + assert_eq!((px.width(), px.height()), (1, rows)); + assert!(px.pixels().all(|p| p.0 == [255, 255, 255, 128]), "white at an alpha of 128"); + } + + /// Patched bytes read the same whole, a byte at a time and after a seek into a patch. + #[test] + fn patched_bytes_read_with_their_patches() { + use std::io::{Read, Seek, SeekFrom}; + let bytes: Vec = (10..30).collect(); + let patched = || Patched { bytes: Cursor::new(&bytes[..]), patches: vec![(4, &[1, 0]), (19, &[7, 7])] }; + let want = [10, 11, 12, 13, 1, 0, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 7]; + let mut whole = Vec::new(); + patched().read_to_end(&mut whole).unwrap(); + assert_eq!(whole, want); + let mut one = patched(); + let bytewise: Vec = (0..want.len()) + .map(|_| { + let mut b = [0]; + assert_eq!(one.read(&mut b).unwrap(), 1); + b[0] + }) + .collect(); + assert_eq!(bytewise, want); + let mut from = patched(); + from.seek(SeekFrom::Start(5)).unwrap(); + let mut b = [9; 3]; + from.read_exact(&mut b).unwrap(); + assert_eq!(b, [0, 16, 17], "the second byte of the first patch, then the file"); } /// A grayscale TIFF whose alpha follows another extra sample reads that alpha. diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index f7f39e5358..b66b22d1a8 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -203,15 +203,18 @@ fn a_16_bit_cmyk_tiff_with_alpha_places_at_16_bits() { } /// A CMYK or grayscale TIFF whose alpha follows another extra sample opens and places with that -/// alpha, at 8 and 16 bits, chunky and planar; a CMYK one is not kept as inks. +/// alpha, at 8 and 16 bits, chunky and planar; a CMYK one is not kept as inks. A CMYK one with two +/// more samples than its ExtraSamples values count does too. #[test] fn a_tiff_with_alpha_after_another_extra_sample_places_with_its_alpha() { let none = vec![0u16; 6]; let full = vec![255u16; 6]; let other = vec![9u16, 8, 7, 6, 5, 4]; let alpha = vec![255u16, 128, 0, 255, 64, 1]; + let inks = [full.clone(), none.clone(), none.clone(), none.clone()]; let cases = [ ("CMYK", 5, vec![full.clone(), none.clone(), none.clone(), none.clone(), other.clone(), alpha.clone()]), + ("CMYK, two more samples", 5, [&inks[..], &[other.clone(), alpha.clone(), other.clone(), full.clone()]].concat()), ("gray", 1, vec![full.clone(), other.clone(), alpha.clone()]), ]; for (what, photometric, channels) in cases { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4ae7bd7bc3..af94ba55c1 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1783,7 +1783,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; grayscale TIFFs with an alpha channel or other extra channels place, relink and open, the alpha as the image's transparency (#1466 in part); PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546). - TIFFs that store each channel as its own plane (PlanarConfiguration 2) place, relink and open as the same image stored pixel by pixel does, 8 and 16 bits: grayscale with an alpha channel or other extra channels, and CMYK with or without them. A planar CMYK TIFF keeps its ink amounts and is stored as a CMYK TIFF with each pixel's inks together, at the file's resolution. A TIFF whose header claims more strips or tiles than can be counted is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`). - A 16-bit CMYK or grayscale TIFF with an alpha channel is converted from its 16-bit samples, and only the resulting color and alpha are rounded to 8 bits; associated alpha is divided out at 16 bits, and a pixel whose associated alpha is zero has no color. [TIFF 6.0](https://printtechnologies.org/standards/files/tiff-v6.pdf) gives extra samples the depth in BitsPerSample, and its Associated Alpha Handling section states the arithmetic and the zero-alpha rule for RGB data; applying them to CMYK and gray samples is VectorCraft's own rule. Each sample was cut to its high byte first, so a faint ink or a low alpha could be lost (`crates/doc/src/cmyk.rs`, `crates/engine/src/cmd/fileio/tests.rs`). - - A CMYK or grayscale TIFF whose alpha channel comes after another extra channel places and opens with that alpha, 8 and 16 bits, chunky or planar, and the other extra channels are left out. A channel that the file does not mark as alpha is not read as transparency, even when its ExtraSamples tag has fewer values than the file has extra channels, and a white-is-zero grayscale TIFF with an alpha channel opens too. [TIFF 6.0](https://printtechnologies.org/standards/files/tiff-v6.pdf) gives each extra channel its own ExtraSamples value, in order (pp. 31–32), and tells readers to skip extra channels they do not use (p. 27); reading the first channel marked as alpha is VectorCraft's own rule (`crates/doc/src/cmyk.rs`, `crates/engine/src/cmd/fileio/tests.rs`). + - A CMYK or grayscale TIFF whose alpha channel comes after another extra channel places and opens with that alpha, 8 and 16 bits, chunky or planar, and the other extra channels are left out. A channel that the file does not mark as alpha is not read as transparency, even when its ExtraSamples tag has fewer values than the file has extra channels, and a white-is-zero grayscale TIFF with an alpha channel opens too. A document saved with such a CMYK TIFF as a CMYK image keeps it as one, with its ink amounts. Such a TIFF is read when its samples take more than 256 MB, up to the decoding allowance. [TIFF 6.0](https://printtechnologies.org/standards/files/tiff-v6.pdf) gives each extra channel its own ExtraSamples value, in order (pp. 31–32), and tells readers to skip extra channels they do not use (p. 27); reading the first channel marked as alpha is VectorCraft's own rule (`crates/doc/src/cmyk.rs`, `crates/engine/src/cmd/fileio/tests.rs`). - 1-bit TIFFs compressed with CCITT Group 3 (ITU-T T.4) place, relink and open: Compression 2 (modified Huffman) and Compression 3 with one-dimensional rows or, with T4Options bit 0, two-dimensional ones, with or without fill bits, in strips or tiles, with either FillOrder and either polarity. Rows in T.4's uncompressed mode are not read. A file whose pixels would take more than the decoding allowance as RGBA is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`). - EPS and PostScript import keeps a CMYK image's ink amounts, as PDF import does: the samples of `image` and `colorimage` in a CMYK color space, and of a CMYK JPEG read through `DCTDecode`, come in as a CMYK TIFF, read through the image's Decode array, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask (`crates/eps/src/import/tests_images.rs`). Such images came in as RGB PNGs. - EPS and PostScript import reads image data through `CCITTFaxDecode`: Group 4 (ITU-T T.6) and Group 3 one-dimensional and mixed (ITU-T T.4) data, with the filter's `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1` parameters, decodes to 1-bit rows for `image` and `imagemask`. Damaged data keeps the rows decoded before it (damage in the first row is a PostScript `ioerror`), and parameters out of range or past the 256 MiB limit on decoded data are refused (`crates/eps/src/import/tests_images.rs`). Reading such a file stopped at an error naming the filter, and the file opened from a fallback, such as its preview or the art drawn before the image, or did not open. From 82d675e0b2072c2bce64d9d1781ad140a27944b1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 10:10:54 -0800 Subject: [PATCH 3/8] TIFF: the decoder's default buffer limit is 256 MiB The doc comments of with_room and of the large-file test, and the progress-doc line, gave the tiff decoder's default decoding buffer (268,435,456 bytes) as 256 MB. --- crates/doc/src/cmyk.rs | 4 ++-- docs/target-app-parity.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/doc/src/cmyk.rs b/crates/doc/src/cmyk.rs index 032fcce596..8543d22d5e 100644 --- a/crates/doc/src/cmyk.rs +++ b/crates/doc/src/cmyk.rs @@ -249,7 +249,7 @@ fn cmyk_inks(kind: tiff::ColorType, d: &mut tiff::decoder::Decoder } /// `d` with room to decode `n` pixels of `per` samples of `bits` bits each, when they take more -/// than the decoder's own limit (256 MB of samples); `None` when they take more than +/// than the decoder's own limit (256 MiB of samples); `None` when they take more than /// [`crate::MAX_RASTER_ALLOC`]. fn with_room(d: tiff::decoder::Decoder, n: usize, per: usize, bits: u8) -> Option> { let need = n.checked_mul(per)?.checked_mul(usize::from(bits.div_ceil(8)))?; @@ -1161,7 +1161,7 @@ mod tests { f } - /// A TIFF with extra samples whose samples take more than the decoder's own limit of 256 MB + /// A TIFF with extra samples whose samples take more than the decoder's own limit of 256 MiB /// reads: here 65,537 pixels of 4,096 samples, 4,096 bytes more than the limit. #[test] fn a_tiff_with_extra_samples_past_the_decoders_limit_reads() { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index af94ba55c1..5984479b3f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1783,7 +1783,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; grayscale TIFFs with an alpha channel or other extra channels place, relink and open, the alpha as the image's transparency (#1466 in part); PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546). - TIFFs that store each channel as its own plane (PlanarConfiguration 2) place, relink and open as the same image stored pixel by pixel does, 8 and 16 bits: grayscale with an alpha channel or other extra channels, and CMYK with or without them. A planar CMYK TIFF keeps its ink amounts and is stored as a CMYK TIFF with each pixel's inks together, at the file's resolution. A TIFF whose header claims more strips or tiles than can be counted is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`). - A 16-bit CMYK or grayscale TIFF with an alpha channel is converted from its 16-bit samples, and only the resulting color and alpha are rounded to 8 bits; associated alpha is divided out at 16 bits, and a pixel whose associated alpha is zero has no color. [TIFF 6.0](https://printtechnologies.org/standards/files/tiff-v6.pdf) gives extra samples the depth in BitsPerSample, and its Associated Alpha Handling section states the arithmetic and the zero-alpha rule for RGB data; applying them to CMYK and gray samples is VectorCraft's own rule. Each sample was cut to its high byte first, so a faint ink or a low alpha could be lost (`crates/doc/src/cmyk.rs`, `crates/engine/src/cmd/fileio/tests.rs`). - - A CMYK or grayscale TIFF whose alpha channel comes after another extra channel places and opens with that alpha, 8 and 16 bits, chunky or planar, and the other extra channels are left out. A channel that the file does not mark as alpha is not read as transparency, even when its ExtraSamples tag has fewer values than the file has extra channels, and a white-is-zero grayscale TIFF with an alpha channel opens too. A document saved with such a CMYK TIFF as a CMYK image keeps it as one, with its ink amounts. Such a TIFF is read when its samples take more than 256 MB, up to the decoding allowance. [TIFF 6.0](https://printtechnologies.org/standards/files/tiff-v6.pdf) gives each extra channel its own ExtraSamples value, in order (pp. 31–32), and tells readers to skip extra channels they do not use (p. 27); reading the first channel marked as alpha is VectorCraft's own rule (`crates/doc/src/cmyk.rs`, `crates/engine/src/cmd/fileio/tests.rs`). + - A CMYK or grayscale TIFF whose alpha channel comes after another extra channel places and opens with that alpha, 8 and 16 bits, chunky or planar, and the other extra channels are left out. A channel that the file does not mark as alpha is not read as transparency, even when its ExtraSamples tag has fewer values than the file has extra channels, and a white-is-zero grayscale TIFF with an alpha channel opens too. A document saved with such a CMYK TIFF as a CMYK image keeps it as one, with its ink amounts. Such a TIFF is read when its samples take more than 256 MiB, up to the decoding allowance. [TIFF 6.0](https://printtechnologies.org/standards/files/tiff-v6.pdf) gives each extra channel its own ExtraSamples value, in order (pp. 31–32), and tells readers to skip extra channels they do not use (p. 27); reading the first channel marked as alpha is VectorCraft's own rule (`crates/doc/src/cmyk.rs`, `crates/engine/src/cmd/fileio/tests.rs`). - 1-bit TIFFs compressed with CCITT Group 3 (ITU-T T.4) place, relink and open: Compression 2 (modified Huffman) and Compression 3 with one-dimensional rows or, with T4Options bit 0, two-dimensional ones, with or without fill bits, in strips or tiles, with either FillOrder and either polarity. Rows in T.4's uncompressed mode are not read. A file whose pixels would take more than the decoding allowance as RGBA is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`). - EPS and PostScript import keeps a CMYK image's ink amounts, as PDF import does: the samples of `image` and `colorimage` in a CMYK color space, and of a CMYK JPEG read through `DCTDecode`, come in as a CMYK TIFF, read through the image's Decode array, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask (`crates/eps/src/import/tests_images.rs`). Such images came in as RGB PNGs. - EPS and PostScript import reads image data through `CCITTFaxDecode`: Group 4 (ITU-T T.6) and Group 3 one-dimensional and mixed (ITU-T T.4) data, with the filter's `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1` parameters, decodes to 1-bit rows for `image` and `imagemask`. Damaged data keeps the rows decoded before it (damage in the first row is a PostScript `ioerror`), and parameters out of range or past the 256 MiB limit on decoded data are refused (`crates/eps/src/import/tests_images.rs`). Reading such a file stopped at an error naming the filter, and the file opened from a fallback, such as its preview or the art drawn before the image, or did not open. From 87b861c480921263ff5623afd524263aa28b1a70 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 10:50:41 -0800 Subject: [PATCH 4/8] TIFF: extra-sample reads count the planar copy and RGBA pixels in the allowance A CMYK or grayscale TIFF with extra samples is now read only when its decoded samples, the copy that puts a planar file's samples pixel by pixel and the RGBA pixels together fit the decoding allowance; the check counted the decoded samples alone, so a small compressed file could make the reader hold about twice the allowance plus the RGBA image. - The decoder may decode no more than the counted samples, and reads no strip or tile of more compressed bytes than the file holds. - A pixel of more than 64 samples is not read (VectorCraft's own limit). --- crates/doc/src/cmyk.rs | 120 +++++++++++++++++++++++++++++--------- docs/target-app-parity.md | 2 +- 2 files changed, 95 insertions(+), 27 deletions(-) diff --git a/crates/doc/src/cmyk.rs b/crates/doc/src/cmyk.rs index 8543d22d5e..a94b7f7a4f 100644 --- a/crates/doc/src/cmyk.rs +++ b/crates/doc/src/cmyk.rs @@ -248,21 +248,40 @@ fn cmyk_inks(kind: tiff::ColorType, d: &mut tiff::decoder::Decoder matches!(kind, tiff::ColorType::CMYK(8 | 16)) && !extra_samples(d).iter().any(|e| matches!(e, 1 | 2)) } -/// `d` with room to decode `n` pixels of `per` samples of `bits` bits each, when they take more -/// than the decoder's own limit (256 MiB of samples); `None` when they take more than -/// [`crate::MAX_RASTER_ALLOC`]. -fn with_room(d: tiff::decoder::Decoder, n: usize, per: usize, bits: u8) -> Option> { +/// Most samples a pixel of a TIFF with extra samples is read with, its color included: +/// VectorCraft's own limit. +const MAX_EXTRA_SAMPLES_PIXEL: usize = 64; + +/// `d`, the decoder of a TIFF of `file` bytes, set to decode `n` pixels of `per` samples of `bits` +/// bits each into RGBA. `None` when a pixel has more than [`MAX_EXTRA_SAMPLES_PIXEL`] samples, and +/// when the decoded samples, the copy that puts a planar file's samples pixel by pixel and the +/// RGBA pixels together take more than [`crate::MAX_RASTER_ALLOC`]. The decoder may then decode +/// no more than those samples, and read no strip or tile of more compressed bytes than the file +/// holds (nor more than its own limit of 128 MiB). +fn with_room( + mut d: tiff::decoder::Decoder, + file: usize, + n: usize, + per: usize, + bits: u8, +) -> Option> { + if per > MAX_EXTRA_SAMPLES_PIXEL { + return None; + } let need = n.checked_mul(per)?.checked_mul(usize::from(bits.div_ceil(8)))?; - if u64::try_from(need).ok()? > crate::MAX_RASTER_ALLOC { + let copies = if per > 1 && planar(&mut d) { 2 } else { 1 }; + let peak = u64::try_from(need).ok()?.checked_mul(copies)?.checked_add(u64::try_from(n).ok()?.checked_mul(4)?)?; + if peak > crate::MAX_RASTER_ALLOC { return None; } let mut limits = tiff::decoder::Limits::default(); - limits.decoding_buffer_size = limits.decoding_buffer_size.max(need); + limits.decoding_buffer_size = need; + limits.intermediate_buffer_size = limits.intermediate_buffer_size.min(file); Some(d.with_limits(limits)) } /// Does the TIFF `d` reads store one plane per channel (PlanarConfiguration 2)? -fn planar(d: &mut tiff::decoder::Decoder>) -> bool { +fn planar(d: &mut tiff::decoder::Decoder) -> bool { d.find_tag_unsigned::(tiff::tags::Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1) } @@ -646,7 +665,9 @@ pub fn turned_cmyk(bytes: &[u8], orientation: image::metadata::Orientation, ppi: /// together or one plane per channel. A CMYK file with two or more samples past its inks and its /// ExtraSamples values is read when one of those values names an alpha. `None` for other images, /// CMYK ones without alpha (their inks are read by [`ImageBlob::cmyk`]), ones larger than -/// [`MAX_CMYK_PIXELS`] and ones whose samples take more than [`crate::MAX_RASTER_ALLOC`]. +/// [`MAX_CMYK_PIXELS`], ones with more than [`MAX_EXTRA_SAMPLES_PIXEL`] samples a pixel and ones +/// whose samples, with the copy a planar file needs and the RGBA pixels, take more than +/// [`crate::MAX_RASTER_ALLOC`]. pub fn extra_samples_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3]) -> Option { use tiff::tags::Tag; let mut d = tiff_decoder(bytes)?; @@ -677,9 +698,9 @@ pub fn extra_samples_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3]) let mut d = tiff::decoder::Decoder::new(as_black_is_zero(bytes)?).ok()?; let tiff::ColorType::Multiband { bit_depth: bits @ (8 | 16), num_samples } = d.colortype().ok()? else { return None }; let samples = usize::from(num_samples); - (read_samples_at_depth(&mut with_room(d, n, samples, bits)?, n, samples)?, samples) + (read_samples_at_depth(&mut with_room(d, bytes.len(), n, samples, bits)?, n, samples)?, samples) } else { - (read_samples_at_depth(&mut with_room(d, n, given, kind?.bit_depth())?, n, given)?, given) + (read_samples_at_depth(&mut with_room(d, bytes.len(), n, given, kind?.bit_depth())?, n, given)?, given) }; // (the alpha's sample in a pixel, is it associated?), when the pixel has that sample. let alpha = alpha.map(|(i, associated)| (colour + i, associated)).filter(|(at, _)| *at < samples); @@ -1124,32 +1145,46 @@ mod tests { assert_eq!(exif_orientation(&turned), Some(image::metadata::Orientation::Rotate90)); } - /// A white-is-zero grayscale TIFF one pixel wide and `rows` tall, each pixel `samples` samples - /// of 8 bits: a level of 0, an unassociated alpha of 128, then zeros. Each row is a strip, and - /// every strip is the same bytes of the file. - fn tall_gray_tiff(rows: u32, samples: u16) -> Vec { + /// A white-is-zero grayscale TIFF `width` pixels wide and `rows` tall (2 or more), each pixel + /// `samples` samples of 8 bits: a level of 0, an unassociated alpha of 128, then zeros. Each + /// row is a strip, and every strip is the same bytes of the file. + fn tall_gray_tiff(width: u32, rows: u32, samples: u16) -> Vec { + gray_tiff(width, rows, samples, false) + } + + /// [`tall_gray_tiff`], or with `planar` one plane per sample, each row of each plane a strip + /// of zeros, every strip the same bytes of the file. + fn gray_tiff(width: u32, rows: u32, samples: u16, planar: bool) -> Vec { let at = |f: &Vec| u32::try_from(f.len()).unwrap(); let mut f = b"II*\0\0\0\0\0".to_vec(); let row = at(&f); - f.extend([0, 128]); - f.resize(f.len() + usize::from(samples) - 2, 0); + if planar { + f.resize(f.len() + usize::try_from(width).unwrap(), 0); + } else { + for _ in 0..width { + f.extend([0, 128]); + f.resize(f.len() + usize::from(samples) - 2, 0); + } + } + let (strips, strip) = if planar { (rows * u32::from(samples), width) } else { (rows, width * u32::from(samples)) }; let offsets = at(&f); - f.extend((0..rows).flat_map(|_| row.to_le_bytes())); + f.extend((0..strips).flat_map(|_| row.to_le_bytes())); let counts = at(&f); - f.extend((0..rows).flat_map(|_| u32::from(samples).to_le_bytes())); + f.extend((0..strips).flat_map(|_| strip.to_le_bytes())); let ifd = at(&f); f[4..8].copy_from_slice(&ifd.to_le_bytes()); // (tag, type (3 SHORT, 4 LONG), count, value or offset) let entries = [ - (256u16, 4u16, 1, 1), + (256u16, 4u16, 1, width), (257, 4, 1, rows), (258, 3, 1, 8), (259, 3, 1, 1), (262, 3, 1, 0), - (273, 4, rows, offsets), + (273, 4, strips, offsets), (277, 3, 1, u32::from(samples)), (278, 4, 1, 1), - (279, 4, rows, counts), + (279, 4, strips, counts), + (284, 3, 1, if planar { 2 } else { 1 }), (338, 3, 1, 2), ]; f.extend(u16::try_from(entries.len()).unwrap().to_le_bytes()); @@ -1162,16 +1197,49 @@ mod tests { } /// A TIFF with extra samples whose samples take more than the decoder's own limit of 256 MiB - /// reads: here 65,537 pixels of 4,096 samples, 4,096 bytes more than the limit. + /// reads: here 256 × 16,385 pixels of 64 samples, 16,384 bytes more than the limit. #[test] fn a_tiff_with_extra_samples_past_the_decoders_limit_reads() { - let (rows, samples) = (65_537, 4096); - assert!(u64::from(rows) * u64::from(samples) > 256 << 20); - let px = extra_samples_tiff_rgba(&tall_gray_tiff(rows, samples), |_| [0.0; 3]).expect("reads"); - assert_eq!((px.width(), px.height()), (1, rows)); + let (width, rows, samples) = (256, 16_385, 64); + assert!(u64::from(width * rows) * u64::from(samples) > 256 << 20); + let px = extra_samples_tiff_rgba(&tall_gray_tiff(width, rows, samples), |_| [0.0; 3]).expect("reads"); + assert_eq!((px.width(), px.height()), (width, rows)); assert!(px.pixels().all(|p| p.0 == [255, 255, 255, 128]), "white at an alpha of 128"); } + /// A pixel of more than [`MAX_EXTRA_SAMPLES_PIXEL`] samples is not read. + #[test] + fn a_tiff_with_too_many_samples_a_pixel_is_not_read() { + let samples = u16::try_from(MAX_EXTRA_SAMPLES_PIXEL).unwrap(); + assert!(extra_samples_tiff_rgba(&tall_gray_tiff(2, 2, samples), |_| [0.0; 3]).is_some(), "at the limit: reads"); + assert!(extra_samples_tiff_rgba(&tall_gray_tiff(2, 2, samples + 1), |_| [0.0; 3]).is_none(), "past it: not read"); + } + + /// A TIFF whose decoded samples fit [`crate::MAX_RASTER_ALLOC`] but not with its RGBA pixels + /// beside them is refused before its samples are decoded; so is one whose samples fit with + /// the RGBA pixels but not with the copy a planar file needs. The file holds one row of pixels, + /// which every strip reuses. + #[test] + fn a_tiff_whose_decode_with_its_rgba_takes_more_than_the_allowance_is_refused() { + let side = 8192u32; + let n = usize::try_from(u64::from(side) * u64::from(side)).unwrap(); + assert_eq!(u64::try_from(n).unwrap(), MAX_CMYK_PIXELS); + let max = usize::try_from(crate::MAX_RASTER_ALLOC).unwrap(); + // One sample fewer than fills the allowance: the samples alone fit. + let per = max / n - 1; + assert!(n * per <= max && n * per + 4 * n > max); + let bytes = tall_gray_tiff(side, side, u16::try_from(per).unwrap()); + let decoder = || tiff::decoder::Decoder::new(Cursor::new(&bytes[..])).unwrap(); + assert!(with_room(decoder(), bytes.len(), n, per, 8).is_none(), "refused before the decode"); + assert!(extra_samples_tiff_rgba(&bytes, |_| [0.0; 3]).is_none()); + // Half the allowance in samples fits with the RGBA pixels, but not twice over. + let half = max / n / 2; + assert!(with_room(decoder(), bytes.len(), n, half, 8).is_some(), "stored together: fits"); + let planar = gray_tiff(side, side, u16::try_from(half).unwrap(), true); + let decoder = tiff::decoder::Decoder::new(Cursor::new(&planar[..])).unwrap(); + assert!(with_room(decoder, planar.len(), n, half, 8).is_none(), "one plane per sample: refused"); + } + /// Patched bytes read the same whole, a byte at a time and after a seek into a patch. #[test] fn patched_bytes_read_with_their_patches() { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5984479b3f..5ff43bdd40 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1783,7 +1783,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; grayscale TIFFs with an alpha channel or other extra channels place, relink and open, the alpha as the image's transparency (#1466 in part); PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546). - TIFFs that store each channel as its own plane (PlanarConfiguration 2) place, relink and open as the same image stored pixel by pixel does, 8 and 16 bits: grayscale with an alpha channel or other extra channels, and CMYK with or without them. A planar CMYK TIFF keeps its ink amounts and is stored as a CMYK TIFF with each pixel's inks together, at the file's resolution. A TIFF whose header claims more strips or tiles than can be counted is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`). - A 16-bit CMYK or grayscale TIFF with an alpha channel is converted from its 16-bit samples, and only the resulting color and alpha are rounded to 8 bits; associated alpha is divided out at 16 bits, and a pixel whose associated alpha is zero has no color. [TIFF 6.0](https://printtechnologies.org/standards/files/tiff-v6.pdf) gives extra samples the depth in BitsPerSample, and its Associated Alpha Handling section states the arithmetic and the zero-alpha rule for RGB data; applying them to CMYK and gray samples is VectorCraft's own rule. Each sample was cut to its high byte first, so a faint ink or a low alpha could be lost (`crates/doc/src/cmyk.rs`, `crates/engine/src/cmd/fileio/tests.rs`). - - A CMYK or grayscale TIFF whose alpha channel comes after another extra channel places and opens with that alpha, 8 and 16 bits, chunky or planar, and the other extra channels are left out. A channel that the file does not mark as alpha is not read as transparency, even when its ExtraSamples tag has fewer values than the file has extra channels, and a white-is-zero grayscale TIFF with an alpha channel opens too. A document saved with such a CMYK TIFF as a CMYK image keeps it as one, with its ink amounts. Such a TIFF is read when its samples take more than 256 MiB, up to the decoding allowance. [TIFF 6.0](https://printtechnologies.org/standards/files/tiff-v6.pdf) gives each extra channel its own ExtraSamples value, in order (pp. 31–32), and tells readers to skip extra channels they do not use (p. 27); reading the first channel marked as alpha is VectorCraft's own rule (`crates/doc/src/cmyk.rs`, `crates/engine/src/cmd/fileio/tests.rs`). + - A CMYK or grayscale TIFF whose alpha channel comes after another extra channel places and opens with that alpha, 8 and 16 bits, chunky or planar, and the other extra channels are left out. A channel that the file does not mark as alpha is not read as transparency, even when its ExtraSamples tag has fewer values than the file has extra channels, and a white-is-zero grayscale TIFF with an alpha channel opens too. A document saved with such a CMYK TIFF as a CMYK image keeps it as one, with its ink amounts. Such a TIFF is read when its samples take more than 256 MiB, as long as its samples, the copy a planar file needs and the RGBA pixels together fit the decoding allowance; a TIFF with more than 64 samples a pixel is not read, which is VectorCraft's own limit. [TIFF 6.0](https://printtechnologies.org/standards/files/tiff-v6.pdf) gives each extra channel its own ExtraSamples value, in order (pp. 31–32), and tells readers to skip extra channels they do not use (p. 27); reading the first channel marked as alpha is VectorCraft's own rule (`crates/doc/src/cmyk.rs`, `crates/engine/src/cmd/fileio/tests.rs`). - 1-bit TIFFs compressed with CCITT Group 3 (ITU-T T.4) place, relink and open: Compression 2 (modified Huffman) and Compression 3 with one-dimensional rows or, with T4Options bit 0, two-dimensional ones, with or without fill bits, in strips or tiles, with either FillOrder and either polarity. Rows in T.4's uncompressed mode are not read. A file whose pixels would take more than the decoding allowance as RGBA is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`). - EPS and PostScript import keeps a CMYK image's ink amounts, as PDF import does: the samples of `image` and `colorimage` in a CMYK color space, and of a CMYK JPEG read through `DCTDecode`, come in as a CMYK TIFF, read through the image's Decode array, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask (`crates/eps/src/import/tests_images.rs`). Such images came in as RGB PNGs. - EPS and PostScript import reads image data through `CCITTFaxDecode`: Group 4 (ITU-T T.6) and Group 3 one-dimensional and mixed (ITU-T T.4) data, with the filter's `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1` parameters, decodes to 1-bit rows for `image` and `imagemask`. Damaged data keeps the rows decoded before it (damage in the first row is a PostScript `ioerror`), and parameters out of range or past the 256 MiB limit on decoded data are refused (`crates/eps/src/import/tests_images.rs`). Reading such a file stopped at an error naming the filter, and the file opened from a fallback, such as its preview or the art drawn before the image, or did not open. From 3a834f705a636af07099ab0234950de62bd25958 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 11:44:57 -0800 Subject: [PATCH 5/8] TIFF: refuse files of over 64 samples a pixel from their tags The 64-sample limit is now checked against the file's SamplesPerPixel before anything is decoded. It was checked against the samples the decoder gives, which is five for any CMYK file whose first ExtraSamples value is alpha, so a small file with 65,535 samples a pixel was read and the decoder took a row of every sample (1 GiB for a 16-bit file 8,192 pixels wide) before reading its data. - The row the decoder reads each strip or tile into, when the file has more samples a pixel than it gives, counts in the allowance. - Files whose samples aren't unsigned integers are refused from their tags; no reader takes them. - The same checks run before CMYK inks and plates are decoded. - The copy that puts a planar file's samples pixel by pixel is allocated once, at its length, so the reader holds no more than it counts. --- crates/doc/src/cmyk.rs | 189 ++++++++++++++++++++++++++++++++++---- docs/target-app-parity.md | 2 +- 2 files changed, 174 insertions(+), 17 deletions(-) diff --git a/crates/doc/src/cmyk.rs b/crates/doc/src/cmyk.rs index a94b7f7a4f..25c405665a 100644 --- a/crates/doc/src/cmyk.rs +++ b/crates/doc/src/cmyk.rs @@ -248,16 +248,45 @@ fn cmyk_inks(kind: tiff::ColorType, d: &mut tiff::decoder::Decoder matches!(kind, tiff::ColorType::CMYK(8 | 16)) && !extra_samples(d).iter().any(|e| matches!(e, 1 | 2)) } -/// Most samples a pixel of a TIFF with extra samples is read with, its color included: -/// VectorCraft's own limit. +/// Most samples a pixel of a TIFF is read with, its color and extra samples included, as the +/// file's SamplesPerPixel counts them: VectorCraft's own limit. const MAX_EXTRA_SAMPLES_PIXEL: usize = 64; +/// The bytes of the row the decoder of the TIFF `d` reads takes before it reads each strip or tile +/// of a file that stores a pixel's samples together and has more of them (its SamplesPerPixel) +/// than the decoder gives: one row of the strip or tile, every sample of each pixel. 0 for other +/// files. `None` when the file has more than [`MAX_EXTRA_SAMPLES_PIXEL`] samples a pixel, when its +/// samples aren't unsigned integers (a SampleFormat other than 1, which the readers don't take, and +/// which the floating-point predictor needs) and when its color type isn't read. Reads only the +/// file's tags. +fn row_buffer(d: &mut tiff::decoder::Decoder) -> Option { + use tiff::tags::Tag; + let kind = d.colortype().ok()?; + let all = d.find_tag_unsigned::(Tag::SamplesPerPixel).ok()?.unwrap_or(1); + if usize::from(all) > MAX_EXTRA_SAMPLES_PIXEL { + return None; + } + if d.find_tag_unsigned_vec::(Tag::SampleFormat).ok()?.is_some_and(|f| f.iter().any(|&f| f != 1)) { + return None; + } + if all <= kind.num_samples() || planar(d) { + return Some(0); + } + // A strip is as wide as the image, a tile as its TileWidth. + let width = match d.get_chunk_type() { + tiff::decoder::ChunkType::Tile => d.find_tag_unsigned::(Tag::TileWidth).ok()??, + tiff::decoder::ChunkType::Strip => d.dimensions().ok()?.0, + }; + u64::from(width).checked_mul(u64::from(all))?.checked_mul(u64::from(kind.bit_depth().div_ceil(8))) +} + /// `d`, the decoder of a TIFF of `file` bytes, set to decode `n` pixels of `per` samples of `bits` -/// bits each into RGBA. `None` when a pixel has more than [`MAX_EXTRA_SAMPLES_PIXEL`] samples, and -/// when the decoded samples, the copy that puts a planar file's samples pixel by pixel and the -/// RGBA pixels together take more than [`crate::MAX_RASTER_ALLOC`]. The decoder may then decode -/// no more than those samples, and read no strip or tile of more compressed bytes than the file -/// holds (nor more than its own limit of 128 MiB). +/// bits each into RGBA. `None` when the file has more than [`MAX_EXTRA_SAMPLES_PIXEL`] samples a +/// pixel, and when the decoded samples, the copy that puts a planar file's samples pixel by pixel, +/// the row the decoder reads each strip or tile into ([`row_buffer`]) and the RGBA pixels together +/// take more than [`crate::MAX_RASTER_ALLOC`]. The decoder may then decode no more than those +/// samples, and read no strip or tile of more compressed bytes than the file holds (nor more than +/// its own limit of 128 MiB). fn with_room( mut d: tiff::decoder::Decoder, file: usize, @@ -268,9 +297,10 @@ fn with_room( if per > MAX_EXTRA_SAMPLES_PIXEL { return None; } + let row = row_buffer(&mut d)?; let need = n.checked_mul(per)?.checked_mul(usize::from(bits.div_ceil(8)))?; let copies = if per > 1 && planar(&mut d) { 2 } else { 1 }; - let peak = u64::try_from(need).ok()?.checked_mul(copies)?.checked_add(u64::try_from(n).ok()?.checked_mul(4)?)?; + let peak = u64::try_from(need).ok()?.checked_mul(copies)?.checked_add(u64::try_from(n).ok()?.checked_mul(4)?)?.checked_add(row)?; if peak > crate::MAX_RASTER_ALLOC { return None; } @@ -289,7 +319,9 @@ fn planar(d: &mut tiff::decoder::Decoder) - /// type has), pixel by pixel and 8 bits each (16-bit samples to their high byte), whether the file /// stores each pixel's samples together or one plane per channel. `None` when they don't decode to /// that many samples, which includes images whose planes together need more than the decoder's -/// limit (256 MB of samples). +/// limit (256 MB of samples), and, before any decoding, files with more than +/// [`MAX_EXTRA_SAMPLES_PIXEL`] samples a pixel and ones whose samples with the row the decoder +/// reads each strip or tile into ([`row_buffer`]) take more than [`crate::MAX_RASTER_ALLOC`]. pub(crate) fn read_samples(d: &mut tiff::decoder::Decoder>, n: usize, per: usize) -> Option> { Some(match read_samples_at_depth(d, n, per)? { Samples::Eight(v) => v, @@ -322,6 +354,19 @@ enum SampleSlice<'a> { /// [`read_samples`], keeping 16-bit samples at 16 bits. fn read_samples_at_depth(d: &mut tiff::decoder::Decoder, n: usize, per: usize) -> Option { + // Checked from the tags, before anything is decoded. + let row = row_buffer(d)?; + let bytes = u64::from(d.colortype().ok()?.bit_depth().div_ceil(8)); + let need = u64::try_from(n).ok()?.checked_mul(u64::try_from(per).ok()?)?.checked_mul(bytes)?; + if need.checked_add(row)? > crate::MAX_RASTER_ALLOC { + return None; + } + decode_samples(d, n, per) +} + +/// [`read_samples_at_depth`] without its checks, for a decoder that [`with_room`] made. Its tags +/// can't be read once its limits are set: the decoder holds a tag's values to its decoding limit. +fn decode_samples(d: &mut tiff::decoder::Decoder, n: usize, per: usize) -> Option { let mut buf = tiff::decoder::DecodingResult::U8(Vec::new()); let layout = d.read_image_to_buffer(&mut buf).ok()?; match buf { @@ -332,7 +377,8 @@ fn read_samples_at_depth(d: &mut tiff::decoder } /// `data`, the samples of `n` pixels, `per` to a pixel, stored in `planes` planes, as the samples -/// pixel by pixel; `None` when it doesn't hold that many samples or a plane per sample. +/// pixel by pixel; `None` when it doesn't hold that many samples or a plane per sample. The copy +/// of a planar file's samples is allocated once, at its length. fn chunky(data: Vec, planes: usize, n: usize, per: usize) -> Option> { if Some(data.len()) != n.checked_mul(per) { return None; @@ -344,7 +390,13 @@ fn chunky(data: Vec, planes: usize, n: usize, per: usize) -> Option< return None; } let planes: Vec<&[T]> = data.chunks_exact(n).collect(); - Some((0..n).flat_map(|i| planes.iter().filter_map(move |p| p.get(i).copied())).collect()) + let mut out = Vec::with_capacity(data.len()); + for i in 0..n { + for p in &planes { + out.push(*p.get(i)?); + } + } + Some(out) } /// The color space an ICC profile's header names (bytes 16 to 19, such as `RGB `, `GRAY` or @@ -665,9 +717,10 @@ pub fn turned_cmyk(bytes: &[u8], orientation: image::metadata::Orientation, ppi: /// together or one plane per channel. A CMYK file with two or more samples past its inks and its /// ExtraSamples values is read when one of those values names an alpha. `None` for other images, /// CMYK ones without alpha (their inks are read by [`ImageBlob::cmyk`]), ones larger than -/// [`MAX_CMYK_PIXELS`], ones with more than [`MAX_EXTRA_SAMPLES_PIXEL`] samples a pixel and ones -/// whose samples, with the copy a planar file needs and the RGBA pixels, take more than -/// [`crate::MAX_RASTER_ALLOC`]. +/// [`MAX_CMYK_PIXELS`], ones whose SamplesPerPixel is more than [`MAX_EXTRA_SAMPLES_PIXEL`] and +/// ones whose samples, with the copy a planar file needs, the row the decoder reads each strip or +/// tile into and the RGBA pixels, take more than [`crate::MAX_RASTER_ALLOC`]; those are refused +/// from the file's tags, before any decoding. pub fn extra_samples_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3]) -> Option { use tiff::tags::Tag; let mut d = tiff_decoder(bytes)?; @@ -698,9 +751,9 @@ pub fn extra_samples_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3]) let mut d = tiff::decoder::Decoder::new(as_black_is_zero(bytes)?).ok()?; let tiff::ColorType::Multiband { bit_depth: bits @ (8 | 16), num_samples } = d.colortype().ok()? else { return None }; let samples = usize::from(num_samples); - (read_samples_at_depth(&mut with_room(d, bytes.len(), n, samples, bits)?, n, samples)?, samples) + (decode_samples(&mut with_room(d, bytes.len(), n, samples, bits)?, n, samples)?, samples) } else { - (read_samples_at_depth(&mut with_room(d, bytes.len(), n, given, kind?.bit_depth())?, n, given)?, given) + (decode_samples(&mut with_room(d, bytes.len(), n, given, kind?.bit_depth())?, n, given)?, given) }; // (the alpha's sample in a pixel, is it associated?), when the pixel has that sample. let alpha = alpha.map(|(i, associated)| (colour + i, associated)).filter(|(at, _)| *at < samples); @@ -1215,6 +1268,110 @@ mod tests { assert!(extra_samples_tiff_rgba(&tall_gray_tiff(2, 2, samples + 1), |_| [0.0; 3]).is_none(), "past it: not read"); } + /// The header of a one-row CMYK TIFF `width` pixels wide, each pixel `samples` samples of + /// `bits` bits stored together, with the ExtraSamples values `extras` (more than two). Its one + /// strip claims every sample of the row, uncompressed, and holds four bytes of zeros. + fn cmyk_header(width: u32, samples: u16, bits: u16, extras: &[u16]) -> Vec { + assert!(extras.len() > 2, "the values sit outside the entry"); + let mut f = b"II*\0\0\0\0\0".to_vec(); + f.extend([0; 4]); + let at = u32::try_from(f.len()).unwrap(); + f.extend(extras.iter().flat_map(|e| e.to_le_bytes())); + let ifd = u32::try_from(f.len()).unwrap(); + f[4..8].copy_from_slice(&ifd.to_le_bytes()); + let strip = u32::try_from(u64::from(width) * u64::from(samples) * u64::from(bits / 8)).unwrap(); + // (tag, type (3 SHORT, 4 LONG), count, value or offset) + let entries = [ + (256u16, 4u16, 1, width), + (257, 4, 1, 1), + (258, 3, 1, u32::from(bits)), + (259, 3, 1, 1), + (262, 3, 1, 5), + (273, 4, 1, 8), + (277, 3, 1, u32::from(samples)), + (278, 4, 1, 1), + (279, 4, 1, strip), + (284, 3, 1, 1), + (338, 3, u32::try_from(extras.len()).unwrap(), at), + ]; + f.extend(u16::try_from(entries.len()).unwrap().to_le_bytes()); + for (tag, ty, count, value) in entries { + f.extend([tag.to_le_bytes(), ty.to_le_bytes()].concat()); + f.extend([count.to_le_bytes(), value.to_le_bytes()].concat()); + } + f.extend([0; 4]); + f + } + + /// A CMYK TIFF whose first ExtraSamples value is alpha has the color type of CMYK with alpha, + /// five samples, however many samples its pixels have; one with more than + /// [`MAX_EXTRA_SAMPLES_PIXEL`] is refused from its tags, before the decoder takes a row of + /// every sample (here 8,192 × 65,535 × 2 bytes, 1 GiB). Neither is it read as inks. + #[test] + fn a_cmyk_tiff_with_more_samples_a_pixel_than_the_limit_is_refused_before_decoding() { + let (width, samples) = (8192u32, u16::MAX); + for first in [1u16, 0] { + let extras: Vec = std::iter::once(first).chain(std::iter::repeat_n(0, usize::from(samples) - 5)).collect(); + let bytes = cmyk_header(width, samples, 16, &extras); + let mut d = tiff::decoder::Decoder::new(Cursor::new(&bytes[..])).unwrap(); + let kind = d.colortype().unwrap(); + assert_eq!(kind, if first == 1 { tiff::ColorType::CMYKA(16) } else { tiff::ColorType::CMYK(16) }); + assert_eq!(row_buffer(&mut d), None, "{kind:?}: refused from the tags"); + let given = usize::from(kind.num_samples()); + assert!(with_room(d, bytes.len(), usize::try_from(width).unwrap(), given, 16).is_none(), "{kind:?}: refused before the decode"); + assert!(extra_samples_tiff_rgba(&bytes, |_| [0.0; 3]).is_none(), "{kind:?}"); + assert!(ImageBlob::new("image/tiff", bytes).cmyk().is_none(), "{kind:?}: no inks"); + } + } + + /// The row the decoder reads each strip of a CMYK TIFF with alpha into, every sample of the + /// row, counts in the allowance: 2²⁴ pixels of 64 16-bit samples, whose five decoded samples + /// and RGBA pixels fit but not with that row of 2 GiB. + #[test] + fn the_decoders_row_of_every_sample_counts_in_the_allowance() { + let width = 1u32 << 24; + let samples = u16::try_from(MAX_EXTRA_SAMPLES_PIXEL).unwrap(); + let extras: Vec = std::iter::once(2).chain(std::iter::repeat_n(0, usize::from(samples) - 5)).collect(); + let bytes = cmyk_header(width, samples, 16, &extras); + let n = usize::try_from(width).unwrap(); + let (need, rgba, row) = (u64::from(width) * 5 * 2, u64::from(width) * 4, u64::from(width) * u64::from(samples) * 2); + assert!(need + rgba <= crate::MAX_RASTER_ALLOC && need + rgba + row > crate::MAX_RASTER_ALLOC); + let mut d = tiff::decoder::Decoder::new(Cursor::new(&bytes[..])).unwrap(); + assert_eq!(d.colortype().unwrap(), tiff::ColorType::CMYKA(16)); + assert_eq!(row_buffer(&mut d), Some(row)); + assert!(with_room(d, bytes.len(), n, 5, 16).is_none(), "refused before the decode"); + assert!(extra_samples_tiff_rgba(&bytes, |_| [0.0; 3]).is_none()); + } + + /// A TIFF whose samples aren't unsigned integers is refused from its tags. + #[test] + fn a_tiff_of_float_samples_is_refused_from_its_tags() { + use tiff::encoder::colortype::{Gray8, Gray32Float}; + let mut file = Cursor::new(Vec::new()); + tiff::encoder::TiffEncoder::new(&mut file).unwrap().write_image::(2, 1, &[0.25, 0.5]).unwrap(); + let float = file.into_inner(); + let mut d = tiff::decoder::Decoder::new(Cursor::new(&float[..])).unwrap(); + assert_eq!(row_buffer(&mut d), None, "floats: refused"); + assert!(read_samples_at_depth(&mut d, 2, 1).is_none()); + let mut file = Cursor::new(Vec::new()); + tiff::encoder::TiffEncoder::new(&mut file).unwrap().write_image::(2, 1, &[64, 128]).unwrap(); + let bytes = file.into_inner(); + let mut d = tiff::decoder::Decoder::new(Cursor::new(&bytes[..])).unwrap(); + assert_eq!(row_buffer(&mut d), Some(0), "unsigned: no extra row"); + assert_eq!(read_samples_at_depth(&mut d, 2, 1), Some(Samples::Eight(vec![64, 128]))); + } + + /// The copy that puts a planar file's samples pixel by pixel is allocated at its length, so + /// the reader holds no more than the allowance counts. + #[test] + fn the_planar_copy_is_allocated_at_its_length() { + let (n, per) = (3, 5); + let data: Vec = (0..15).collect(); + let out = chunky(data, per, n, per).unwrap(); + assert_eq!(out, [0, 3, 6, 9, 12, 1, 4, 7, 10, 13, 2, 5, 8, 11, 14]); + assert_eq!(out.capacity(), n * per); + } + /// A TIFF whose decoded samples fit [`crate::MAX_RASTER_ALLOC`] but not with its RGBA pixels /// beside them is refused before its samples are decoded; so is one whose samples fit with /// the RGBA pixels but not with the copy a planar file needs. The file holds one row of pixels, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5ff43bdd40..233c6057ad 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1783,7 +1783,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; grayscale TIFFs with an alpha channel or other extra channels place, relink and open, the alpha as the image's transparency (#1466 in part); PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546). - TIFFs that store each channel as its own plane (PlanarConfiguration 2) place, relink and open as the same image stored pixel by pixel does, 8 and 16 bits: grayscale with an alpha channel or other extra channels, and CMYK with or without them. A planar CMYK TIFF keeps its ink amounts and is stored as a CMYK TIFF with each pixel's inks together, at the file's resolution. A TIFF whose header claims more strips or tiles than can be counted is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`). - A 16-bit CMYK or grayscale TIFF with an alpha channel is converted from its 16-bit samples, and only the resulting color and alpha are rounded to 8 bits; associated alpha is divided out at 16 bits, and a pixel whose associated alpha is zero has no color. [TIFF 6.0](https://printtechnologies.org/standards/files/tiff-v6.pdf) gives extra samples the depth in BitsPerSample, and its Associated Alpha Handling section states the arithmetic and the zero-alpha rule for RGB data; applying them to CMYK and gray samples is VectorCraft's own rule. Each sample was cut to its high byte first, so a faint ink or a low alpha could be lost (`crates/doc/src/cmyk.rs`, `crates/engine/src/cmd/fileio/tests.rs`). - - A CMYK or grayscale TIFF whose alpha channel comes after another extra channel places and opens with that alpha, 8 and 16 bits, chunky or planar, and the other extra channels are left out. A channel that the file does not mark as alpha is not read as transparency, even when its ExtraSamples tag has fewer values than the file has extra channels, and a white-is-zero grayscale TIFF with an alpha channel opens too. A document saved with such a CMYK TIFF as a CMYK image keeps it as one, with its ink amounts. Such a TIFF is read when its samples take more than 256 MiB, as long as its samples, the copy a planar file needs and the RGBA pixels together fit the decoding allowance; a TIFF with more than 64 samples a pixel is not read, which is VectorCraft's own limit. [TIFF 6.0](https://printtechnologies.org/standards/files/tiff-v6.pdf) gives each extra channel its own ExtraSamples value, in order (pp. 31–32), and tells readers to skip extra channels they do not use (p. 27); reading the first channel marked as alpha is VectorCraft's own rule (`crates/doc/src/cmyk.rs`, `crates/engine/src/cmd/fileio/tests.rs`). + - A CMYK or grayscale TIFF whose alpha channel comes after another extra channel places and opens with that alpha, 8 and 16 bits, chunky or planar, and the other extra channels are left out. A channel that the file does not mark as alpha is not read as transparency, even when its ExtraSamples tag has fewer values than the file has extra channels, and a white-is-zero grayscale TIFF with an alpha channel opens too. A document saved with such a CMYK TIFF as a CMYK image keeps it as one, with its ink amounts. Such a TIFF is read when its samples take more than 256 MiB, as long as its samples, the copy a planar file needs, the row the decoder reads each strip or tile into and the RGBA pixels together fit the decoding allowance; a TIFF whose SamplesPerPixel is more than 64 is not read, which is VectorCraft's own limit, and both checks run on the file's tags before any decoding. [TIFF 6.0](https://printtechnologies.org/standards/files/tiff-v6.pdf) gives each extra channel its own ExtraSamples value, in order (pp. 31–32), and tells readers to skip extra channels they do not use (p. 27); reading the first channel marked as alpha is VectorCraft's own rule (`crates/doc/src/cmyk.rs`, `crates/engine/src/cmd/fileio/tests.rs`). - 1-bit TIFFs compressed with CCITT Group 3 (ITU-T T.4) place, relink and open: Compression 2 (modified Huffman) and Compression 3 with one-dimensional rows or, with T4Options bit 0, two-dimensional ones, with or without fill bits, in strips or tiles, with either FillOrder and either polarity. Rows in T.4's uncompressed mode are not read. A file whose pixels would take more than the decoding allowance as RGBA is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`). - EPS and PostScript import keeps a CMYK image's ink amounts, as PDF import does: the samples of `image` and `colorimage` in a CMYK color space, and of a CMYK JPEG read through `DCTDecode`, come in as a CMYK TIFF, read through the image's Decode array, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask (`crates/eps/src/import/tests_images.rs`). Such images came in as RGB PNGs. - EPS and PostScript import reads image data through `CCITTFaxDecode`: Group 4 (ITU-T T.6) and Group 3 one-dimensional and mixed (ITU-T T.4) data, with the filter's `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1` parameters, decodes to 1-bit rows for `image` and `imagemask`. Damaged data keeps the rows decoded before it (damage in the first row is a PostScript `ioerror`), and parameters out of range or past the 256 MiB limit on decoded data are refused (`crates/eps/src/import/tests_images.rs`). Reading such a file stopped at an error naming the filter, and the file opened from a fallback, such as its preview or the art drawn before the image, or did not open. From c347abf4992d9b7db357897eea9ec2280815308d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 12:05:50 -0800 Subject: [PATCH 6/8] TIFF: CMYK files with unnamed extra samples past their ExtraSamples values open opaque A CMYK TIFF with two or more samples past its inks and its ExtraSamples values, or with no ExtraSamples tag, which the decoder gives no color type, now opens opaque when none of those values names an alpha. It failed to open before; one that names an alpha already read with it. --- crates/doc/src/cmyk.rs | 20 +++++++++++++++----- docs/target-app-parity.md | 2 +- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/crates/doc/src/cmyk.rs b/crates/doc/src/cmyk.rs index 25c405665a..36a4cb15b1 100644 --- a/crates/doc/src/cmyk.rs +++ b/crates/doc/src/cmyk.rs @@ -715,7 +715,8 @@ pub fn turned_cmyk(bytes: &[u8], orientation: image::metadata::Orientation, ppi: /// divided back out, and a pixel with no associated alpha has no color. 16-bit samples are /// converted at 16 bits, and only the result is rounded to 8 bits. The samples may be stored /// together or one plane per channel. A CMYK file with two or more samples past its inks and its -/// ExtraSamples values is read when one of those values names an alpha. `None` for other images, +/// ExtraSamples values (or with no ExtraSamples tag) is read too, opaque when none of those values +/// names an alpha. `None` for other images, /// CMYK ones without alpha (their inks are read by [`ImageBlob::cmyk`]), ones larger than /// [`MAX_CMYK_PIXELS`], ones whose SamplesPerPixel is more than [`MAX_EXTRA_SAMPLES_PIXEL`] and /// ones whose samples, with the copy a planar file needs, the row the decoder reads each strip or @@ -740,14 +741,14 @@ pub fn extra_samples_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3]) Some(tiff::ColorType::CMYKA(8 | 16)) => (4, if extras.is_empty() { Some((0, false)) } else { named }), Some(tiff::ColorType::CMYK(8 | 16)) if named.is_some() => (4, named), Some(tiff::ColorType::Multiband { bit_depth: 8 | 16, num_samples: 2.. }) if matches!(photometric, Some(0 | 1)) => (1, named), - None if photometric == Some(5) && named.is_some() && uncounted.is_some_and(|c| c > 5) => (4, named), + None if photometric == Some(5) && uncounted.is_some_and(|c| c > 5) => (4, named), _ => return None, }; let n = usize::try_from(pixels(w, h)?).ok()?; // The decoder gives a CMYK pixel its inks and at most one extra sample, and doesn't read // white-is-zero gray with extra samples: those are read as black-is-zero gray, every sample. let given = kind.map_or(0, |k| usize::from(k.num_samples())); - let (data, samples) = if photometric == Some(0) || alpha.is_some_and(|(i, _)| colour + i >= given) { + let (data, samples) = if photometric == Some(0) || kind.is_none() || alpha.is_some_and(|(i, _)| colour + i >= given) { let mut d = tiff::decoder::Decoder::new(as_black_is_zero(bytes)?).ok()?; let tiff::ColorType::Multiband { bit_depth: bits @ (8 | 16), num_samples } = d.colortype().ok()? else { return None }; let samples = usize::from(num_samples); @@ -1169,7 +1170,9 @@ mod tests { } /// A CMYK TIFF of six samples a pixel whose ExtraSamples has only one value: the sample after - /// the inks is alpha only when that value says so, and the sample after it is never alpha. + /// the inks is alpha only when that value says so, and the sample after it is never alpha. Files + /// with more samples than the decoder gives a color type read the same way, opaque when no + /// value names an alpha. #[test] fn a_cmyk_tiff_with_fewer_extra_sample_values_than_samples_reads_only_a_named_alpha() { 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)]; @@ -1182,7 +1185,14 @@ mod tests { let wide = [255, 0, 0, 0, 9, 128, 17, 18]; let px = extra_samples_tiff_rgba(&extras_tiff::(&[0, 2], &wide), naive).expect("reads"); assert_eq!(px.into_raw(), [0, 255, 255, 128], "cyan at the alpha the second value names"); - assert!(extra_samples_tiff_rgba(&extras_tiff::(&[0, 0], &wide), naive).is_none(), "no value names an alpha: not read"); + let px = extra_samples_tiff_rgba(&extras_tiff::(&[0, 0], &wide), naive).expect("no value names an alpha: reads"); + assert_eq!(px.into_raw(), [0, 255, 255, 255], "no value names an alpha: opaque cyan"); + // Six samples and no ExtraSamples tag: the decoder gives the file no color type either. + let mut file = Cursor::new(Vec::new()); + let mut enc = tiff::encoder::TiffEncoder::new(&mut file).unwrap(); + enc.write_image::(1, 1, &wide[..6]).unwrap(); + let px = extra_samples_tiff_rgba(&file.into_inner(), naive).expect("no ExtraSamples tag: reads"); + assert_eq!(px.into_raw(), [0, 255, 255, 255], "no ExtraSamples tag: opaque cyan"); // Seven samples with two values: the decoder reads it as CMYK with alpha. let px = extra_samples_tiff_rgba(&extras_tiff::(&[0, 2], &wide[..7]), naive).expect("reads"); assert_eq!(px.into_raw(), [0, 255, 255, 128], "seven samples: cyan at the alpha the second value names"); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 233c6057ad..9b4824a333 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1783,7 +1783,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; grayscale TIFFs with an alpha channel or other extra channels place, relink and open, the alpha as the image's transparency (#1466 in part); PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546). - TIFFs that store each channel as its own plane (PlanarConfiguration 2) place, relink and open as the same image stored pixel by pixel does, 8 and 16 bits: grayscale with an alpha channel or other extra channels, and CMYK with or without them. A planar CMYK TIFF keeps its ink amounts and is stored as a CMYK TIFF with each pixel's inks together, at the file's resolution. A TIFF whose header claims more strips or tiles than can be counted is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`). - A 16-bit CMYK or grayscale TIFF with an alpha channel is converted from its 16-bit samples, and only the resulting color and alpha are rounded to 8 bits; associated alpha is divided out at 16 bits, and a pixel whose associated alpha is zero has no color. [TIFF 6.0](https://printtechnologies.org/standards/files/tiff-v6.pdf) gives extra samples the depth in BitsPerSample, and its Associated Alpha Handling section states the arithmetic and the zero-alpha rule for RGB data; applying them to CMYK and gray samples is VectorCraft's own rule. Each sample was cut to its high byte first, so a faint ink or a low alpha could be lost (`crates/doc/src/cmyk.rs`, `crates/engine/src/cmd/fileio/tests.rs`). - - A CMYK or grayscale TIFF whose alpha channel comes after another extra channel places and opens with that alpha, 8 and 16 bits, chunky or planar, and the other extra channels are left out. A channel that the file does not mark as alpha is not read as transparency, even when its ExtraSamples tag has fewer values than the file has extra channels, and a white-is-zero grayscale TIFF with an alpha channel opens too. A document saved with such a CMYK TIFF as a CMYK image keeps it as one, with its ink amounts. Such a TIFF is read when its samples take more than 256 MiB, as long as its samples, the copy a planar file needs, the row the decoder reads each strip or tile into and the RGBA pixels together fit the decoding allowance; a TIFF whose SamplesPerPixel is more than 64 is not read, which is VectorCraft's own limit, and both checks run on the file's tags before any decoding. [TIFF 6.0](https://printtechnologies.org/standards/files/tiff-v6.pdf) gives each extra channel its own ExtraSamples value, in order (pp. 31–32), and tells readers to skip extra channels they do not use (p. 27); reading the first channel marked as alpha is VectorCraft's own rule (`crates/doc/src/cmyk.rs`, `crates/engine/src/cmd/fileio/tests.rs`). + - A CMYK or grayscale TIFF whose alpha channel comes after another extra channel places and opens with that alpha, 8 and 16 bits, chunky or planar, and the other extra channels are left out. A channel that the file does not mark as alpha is not read as transparency, even when its ExtraSamples tag has fewer values than the file has extra channels. A CMYK TIFF with two or more extra channels past its ExtraSamples values, or with no ExtraSamples tag, opens opaque when none of them is marked as alpha, and a white-is-zero grayscale TIFF with an alpha channel opens too. A document saved with such a CMYK TIFF as a CMYK image keeps it as one, with its ink amounts. Such a TIFF is read when its samples take more than 256 MiB, as long as its samples, the copy a planar file needs, the row the decoder reads each strip or tile into and the RGBA pixels together fit the decoding allowance; a TIFF whose SamplesPerPixel is more than 64 is not read, which is VectorCraft's own limit, and both checks run on the file's tags before any decoding. [TIFF 6.0](https://printtechnologies.org/standards/files/tiff-v6.pdf) gives each extra channel its own ExtraSamples value, in order (pp. 31–32), and tells readers to skip extra channels they do not use (p. 27); reading the first channel marked as alpha is VectorCraft's own rule (`crates/doc/src/cmyk.rs`, `crates/engine/src/cmd/fileio/tests.rs`). - 1-bit TIFFs compressed with CCITT Group 3 (ITU-T T.4) place, relink and open: Compression 2 (modified Huffman) and Compression 3 with one-dimensional rows or, with T4Options bit 0, two-dimensional ones, with or without fill bits, in strips or tiles, with either FillOrder and either polarity. Rows in T.4's uncompressed mode are not read. A file whose pixels would take more than the decoding allowance as RGBA is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`). - EPS and PostScript import keeps a CMYK image's ink amounts, as PDF import does: the samples of `image` and `colorimage` in a CMYK color space, and of a CMYK JPEG read through `DCTDecode`, come in as a CMYK TIFF, read through the image's Decode array, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask (`crates/eps/src/import/tests_images.rs`). Such images came in as RGB PNGs. - EPS and PostScript import reads image data through `CCITTFaxDecode`: Group 4 (ITU-T T.6) and Group 3 one-dimensional and mixed (ITU-T T.4) data, with the filter's `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1` parameters, decodes to 1-bit rows for `image` and `imagemask`. Damaged data keeps the rows decoded before it (damage in the first row is a PostScript `ioerror`), and parameters out of range or past the 256 MiB limit on decoded data are refused (`crates/eps/src/import/tests_images.rs`). Reading such a file stopped at an error naming the filter, and the file opened from a fallback, such as its preview or the art drawn before the image, or did not open. From ee0a3b7f3f8aad2e862f4c65c5403379bfc2afb7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 12:15:36 -0800 Subject: [PATCH 7/8] TIFF: doc comments name every refusal; the planar copy test checks its room The doc comments of read_samples, with_room and extra_samples_tiff_rgba name the SampleFormat check, and those of tiff_source and tiff_inks name the 64-sample limit, the SampleFormat check and the decoder row check. read_samples gives the decoder's limit as 256 MiB. the_planar_copy_is_allocated_at_its_length copies 33 samples and checks that the copy has room for at least 33 and fewer than 64, the room a copy grown as it was filled ends with, rather than an exact capacity. --- crates/doc/src/cmyk.rs | 41 ++++++++++++++++++++++++++--------------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/crates/doc/src/cmyk.rs b/crates/doc/src/cmyk.rs index 36a4cb15b1..2c957d5779 100644 --- a/crates/doc/src/cmyk.rs +++ b/crates/doc/src/cmyk.rs @@ -282,7 +282,8 @@ fn row_buffer(d: &mut tiff::decoder::Decoder(d: &mut tiff::decoder::Decoder) - /// type has), pixel by pixel and 8 bits each (16-bit samples to their high byte), whether the file /// stores each pixel's samples together or one plane per channel. `None` when they don't decode to /// that many samples, which includes images whose planes together need more than the decoder's -/// limit (256 MB of samples), and, before any decoding, files with more than -/// [`MAX_EXTRA_SAMPLES_PIXEL`] samples a pixel and ones whose samples with the row the decoder -/// reads each strip or tile into ([`row_buffer`]) take more than [`crate::MAX_RASTER_ALLOC`]. +/// limit (256 MiB of samples), and, before any decoding, files with more than +/// [`MAX_EXTRA_SAMPLES_PIXEL`] samples a pixel, files whose samples aren't unsigned integers (a +/// SampleFormat other than 1) and ones whose samples with the row the decoder reads each strip or +/// tile into ([`row_buffer`]) take more than [`crate::MAX_RASTER_ALLOC`]. pub(crate) fn read_samples(d: &mut tiff::decoder::Decoder>, n: usize, per: usize) -> Option> { Some(match read_samples_at_depth(d, n, per)? { Samples::Eight(v) => v, @@ -442,8 +444,11 @@ struct CmykSource { } /// The samples of CMYK TIFF `bytes` (8 or 16 bits), their inks together or one plane per ink, with -/// the TIFF's CMYK ICC profile; `None` for other images, CMYK ones with alpha and ones larger than -/// [`MAX_CMYK_PIXELS`]. +/// the TIFF's CMYK ICC profile; `None` for other images, CMYK ones with alpha, ones larger than +/// [`MAX_CMYK_PIXELS`], and, before any decoding, ones with more than [`MAX_EXTRA_SAMPLES_PIXEL`] +/// samples a pixel (their SamplesPerPixel), ones whose samples aren't unsigned integers (a +/// SampleFormat other than 1) and ones whose samples with the row the decoder reads each strip or +/// tile into ([`row_buffer`]) take more than [`crate::MAX_RASTER_ALLOC`]. fn tiff_source(bytes: &[u8]) -> Option { let (kind, mut d) = tiff(bytes)?; if !cmyk_inks(kind, &mut d) { @@ -657,7 +662,10 @@ fn jpeg_inks(bytes: &[u8]) -> Option { } /// The ink amounts of a CMYK TIFF (16-bit samples to 8 bits), its inks stored together or one plane -/// per ink. +/// per ink. `None` for other images, ones larger than [`MAX_CMYK_PIXELS`], and the files +/// [`read_samples`] refuses: more than [`MAX_EXTRA_SAMPLES_PIXEL`] samples a pixel, samples that +/// aren't unsigned integers, or samples that with the decoder's row take more than +/// [`crate::MAX_RASTER_ALLOC`]. fn tiff_inks(bytes: &[u8]) -> Option { let (kind, mut d) = tiff(bytes)?; let (w, h) = d.dimensions().ok()?; @@ -718,8 +726,8 @@ pub fn turned_cmyk(bytes: &[u8], orientation: image::metadata::Orientation, ppi: /// ExtraSamples values (or with no ExtraSamples tag) is read too, opaque when none of those values /// names an alpha. `None` for other images, /// CMYK ones without alpha (their inks are read by [`ImageBlob::cmyk`]), ones larger than -/// [`MAX_CMYK_PIXELS`], ones whose SamplesPerPixel is more than [`MAX_EXTRA_SAMPLES_PIXEL`] and -/// ones whose samples, with the copy a planar file needs, the row the decoder reads each strip or +/// [`MAX_CMYK_PIXELS`], ones whose SamplesPerPixel is more than [`MAX_EXTRA_SAMPLES_PIXEL`], ones +/// whose samples aren't unsigned integers (a SampleFormat other than 1) and ones whose samples, with the copy a planar file needs, the row the decoder reads each strip or /// tile into and the RGBA pixels, take more than [`crate::MAX_RASTER_ALLOC`]; those are refused /// from the file's tags, before any decoding. pub fn extra_samples_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3]) -> Option { @@ -1372,14 +1380,17 @@ mod tests { } /// The copy that puts a planar file's samples pixel by pixel is allocated at its length, so - /// the reader holds no more than the allowance counts. + /// the reader holds no more than the allowance counts. 33 samples: a copy that grew as it + /// was filled would hold room for 64. #[test] fn the_planar_copy_is_allocated_at_its_length() { - let (n, per) = (3, 5); - let data: Vec = (0..15).collect(); - let out = chunky(data, per, n, per).unwrap(); - assert_eq!(out, [0, 3, 6, 9, 12, 1, 4, 7, 10, 13, 2, 5, 8, 11, 14]); - assert_eq!(out.capacity(), n * per); + let (n, per) = (11u8, 3u8); + let data: Vec = (0..n * per).collect(); + let len = data.len(); + let out = chunky(data, usize::from(per), usize::from(n), usize::from(per)).unwrap(); + let pixel_by_pixel: Vec = (0..n).flat_map(|i| (0..per).map(move |p| p * n + i)).collect(); + assert_eq!(out, pixel_by_pixel); + assert!(out.capacity() >= len && out.capacity() < len.next_power_of_two(), "capacity {} for {len} samples", out.capacity()); } /// A TIFF whose decoded samples fit [`crate::MAX_RASTER_ALLOC`] but not with its RGBA pixels From ddb20988275cafb5fb4c5c1b9fa78eeb842c4973 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 13:47:06 -0800 Subject: [PATCH 8/8] TIFF: rewrap the doc comments of with_room and extra_samples_tiff_rgba --- crates/doc/src/cmyk.rs | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/crates/doc/src/cmyk.rs b/crates/doc/src/cmyk.rs index 2c957d5779..6b72f77a67 100644 --- a/crates/doc/src/cmyk.rs +++ b/crates/doc/src/cmyk.rs @@ -283,11 +283,11 @@ fn row_buffer(d: &mut tiff::decoder::Decoder( mut d: tiff::decoder::Decoder, file: usize, @@ -724,12 +724,12 @@ pub fn turned_cmyk(bytes: &[u8], orientation: image::metadata::Orientation, ppi: /// converted at 16 bits, and only the result is rounded to 8 bits. The samples may be stored /// together or one plane per channel. A CMYK file with two or more samples past its inks and its /// ExtraSamples values (or with no ExtraSamples tag) is read too, opaque when none of those values -/// names an alpha. `None` for other images, -/// CMYK ones without alpha (their inks are read by [`ImageBlob::cmyk`]), ones larger than -/// [`MAX_CMYK_PIXELS`], ones whose SamplesPerPixel is more than [`MAX_EXTRA_SAMPLES_PIXEL`], ones -/// whose samples aren't unsigned integers (a SampleFormat other than 1) and ones whose samples, with the copy a planar file needs, the row the decoder reads each strip or -/// tile into and the RGBA pixels, take more than [`crate::MAX_RASTER_ALLOC`]; those are refused -/// from the file's tags, before any decoding. +/// names an alpha. `None` for other images, CMYK ones without alpha (their inks are read by +/// [`ImageBlob::cmyk`]), ones larger than [`MAX_CMYK_PIXELS`], ones whose SamplesPerPixel is more +/// than [`MAX_EXTRA_SAMPLES_PIXEL`], ones whose samples aren't unsigned integers (a SampleFormat +/// other than 1) and ones whose samples, with the copy a planar file needs, the row the decoder +/// reads each strip or tile into and the RGBA pixels, take more than [`crate::MAX_RASTER_ALLOC`]; +/// those are refused from the file's tags, before any decoding. pub fn extra_samples_tiff_rgba(bytes: &[u8], rgb: impl Fn([f32; 4]) -> [f32; 3]) -> Option { use tiff::tags::Tag; let mut d = tiff_decoder(bytes)?;