diff --git a/Cargo.lock b/Cargo.lock index e6ed044a3..506744722 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1018,6 +1018,7 @@ dependencies = [ "image", "psd", "resvg", + "tiff", ] [[package]] @@ -1052,6 +1053,7 @@ dependencies = [ "kurbo", "log", "thiserror 2.0.21", + "tiff", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 894f4bdd5..bd9237c66 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,8 @@ harfrust = "0.13" vello_cpu = "0.2" vello_common = { version = "0.2", default-features = false } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] } +# CMYK TIFF samples for PDF export (`image` decodes them to RGB); the version `image` uses. +tiff = "0.11" # SVG (same usvg as krilla-svg) and Photoshop composites. resvg = { version = "0.47", default-features = false, features = ["text"] } krilla-svg = "0.8" diff --git a/crates/images/Cargo.toml b/crates/images/Cargo.toml index 63e1c4b32..b7be5df13 100644 --- a/crates/images/Cargo.toml +++ b/crates/images/Cargo.toml @@ -11,5 +11,6 @@ workspace = true [dependencies] designcraft-fonts = { workspace = true } image = { workspace = true } +tiff = { workspace = true } resvg = { workspace = true } psd = { workspace = true } diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs index 2dd82931e..48e32f79b 100644 --- a/crates/images/src/lib.rs +++ b/crates/images/src/lib.rs @@ -105,6 +105,77 @@ pub fn decode_rgba(bytes: &[u8]) -> Option { Some(image::load_from_memory(bytes).ok()?.to_rgba8()) } +/// A CMYK raster's ink values, 8 bits per ink (0 = no ink, 255 = solid), as PDF's DeviceCMYK +/// reads them. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct CmykRaster { + pub width: u32, + pub height: u32, + /// C, M, Y, K for each pixel, row by row: `width * height * 4` bytes. + pub cmyk: Vec, + /// Straight alpha, `width * height` bytes; `None` when the image is opaque. + pub alpha: Option>, +} + +fn tiff_decoder(bytes: &[u8]) -> Option>> { + let tiff = [b"II*\0", b"MM\0*", b"II+\0", b"MM\0+"].iter().any(|m| bytes.starts_with(*m)); + if !tiff { + return None; + } + tiff::decoder::Decoder::new(std::io::Cursor::new(bytes)).ok() +} + +/// A TIFF whose pixels are CMYK (PhotometricInterpretation Separated, four inks). +pub fn is_cmyk_tiff(bytes: &[u8]) -> bool { + tiff_decoder(bytes).and_then(|mut d| d.colortype().ok()).is_some_and(|c| matches!(c, tiff::ColorType::CMYK(_) | tiff::ColorType::CMYKA(_))) +} + +/// The ink values of a CMYK TIFF, which [`decode_rgba`] converts to RGB. 8- or 16-bit CMYK, +/// pixels interleaved, opaque or with unassociated alpha. `None` for anything else — another +/// format, another ink set (InkSet 2), planar storage or premultiplied alpha — and for files +/// over the decoder's 256 MiB limit; [`is_cmyk_tiff`] tells those apart from RGB files. +pub fn decode_cmyk_tiff(bytes: &[u8]) -> Option { + use tiff::tags::Tag; + let mut d = tiff_decoder(bytes)?; + let has_alpha = match d.colortype().ok()? { + tiff::ColorType::CMYK(8 | 16) => false, + tiff::ColorType::CMYKA(8 | 16) => true, + _ => return None, + }; + // InkSet (tag 332): 1 or absent = CMYK; 2 = other inks, named in InkNames. + if d.find_tag_unsigned::(Tag::Unknown(332)).ok()?.is_some_and(|s| s != 1) { + return None; + } + // ExtraSamples 2 = unassociated alpha. Premultiplied CMYK has no meaning as ink values. + if has_alpha && d.find_tag_unsigned_vec::(Tag::ExtraSamples).ok()?.and_then(|v| v.first().copied()) != Some(2) { + return None; + } + let (w, h) = d.dimensions().ok()?; + let pixels = usize::try_from(w).ok()?.checked_mul(usize::try_from(h).ok()?)?; + let samples = if has_alpha { 5 } else { 4 }; + let px = match d.read_image().ok()? { + tiff::decoder::DecodingResult::U8(v) => v, + tiff::decoder::DecodingResult::U16(v) => v.iter().map(|&s| ((u32::from(s) * 255 + 32_767) / 65_535) as u8).collect(), + _ => return None, + }; + // A planar file reads back as its first plane only, and extra non-alpha samples make the + // pixels wider: either way the length is not `pixels * samples`. + if px.len() != pixels.checked_mul(samples)? { + return None; + } + if !has_alpha { + return Some(CmykRaster { width: w, height: h, cmyk: px, alpha: None }); + } + let mut cmyk = Vec::with_capacity(pixels.checked_mul(4)?); + let mut alpha = Vec::with_capacity(pixels); + for &[c, m, y, k, a] in px.as_chunks::<5>().0 { + cmyk.extend([c, m, y, k]); + alpha.push(a); + } + let alpha = alpha.iter().any(|&a| a < 255).then_some(alpha); + Some(CmykRaster { width: w, height: h, cmyk, alpha }) +} + /// An SVG rasterised so its longer side is `max_side` pixels: premultiplied RGBA8. pub fn render_svg(bytes: &[u8], max_side: u32) -> Option<(Vec, u32, u32)> { let tree = svg_tree(bytes)?; @@ -169,6 +240,48 @@ mod tests { assert_eq!(img.get_pixel(1, 0).0, [0, 255, 0, 255]); } + fn tiff_of(w: u32, h: u32, px: &[C::Inner], alpha: bool) -> Vec + where + [C::Inner]: tiff::encoder::TiffValue, + { + let mut b = Vec::new(); + let mut enc = tiff::encoder::TiffEncoder::new(std::io::Cursor::new(&mut b)).unwrap(); + let mut img = enc.new_image::(w, h).unwrap(); + if alpha { + // CMYKA8 already has five samples: only the tag that says the fifth is alpha. + img.encoder().write_tag(tiff::tags::Tag::ExtraSamples, &[2u16][..]).unwrap(); + } + img.write_data(px).unwrap(); + b + } + + /// CMYK TIFFs read back as their ink values; RGB ones aren't CMYK. + #[test] + fn cmyk_tiff_keeps_inks() { + use tiff::encoder::colortype::{CMYK8, CMYK16, CMYKA8, RGB8}; + // 2×1: 100% K, then C0 M100 Y100 K0. + let px = [0, 0, 0, 255, 0, 255, 255, 0]; + let b = tiff_of::(2, 1, &px, false); + assert_eq!(mime(&b), "image/tiff"); + assert!(is_cmyk_tiff(&b)); + assert_eq!(decode_cmyk_tiff(&b), Some(CmykRaster { width: 2, height: 1, cmyk: px.to_vec(), alpha: None })); + // The screen still gets RGB. + assert_eq!(decode_rgba(&b).unwrap().get_pixel(0, 0).0, [0, 0, 0, 255]); + + let b = tiff_of::(2, 1, &[0, 0, 0, 65_535, 0, 32_896, 65_535, 0], false); + assert_eq!(decode_cmyk_tiff(&b).unwrap().cmyk, [0, 0, 0, 255, 0, 128, 255, 0]); + + let b = tiff_of::(2, 1, &[0, 0, 0, 255, 255, 0, 255, 255, 0, 0], true); + let r = decode_cmyk_tiff(&b).unwrap(); + assert_eq!((r.cmyk, r.alpha), (px.to_vec(), Some(vec![255, 0]))); + + let rgb = tiff_of::(1, 1, &[255, 0, 0], false); + assert!(!is_cmyk_tiff(&rgb)); + assert_eq!(decode_cmyk_tiff(&rgb), None); + assert!(!is_cmyk_tiff(b"II*\0"), "truncated"); + assert_eq!(decode_cmyk_tiff(&b[..b.len() / 2]), None, "truncated"); + } + #[test] fn bmp_decodes() { let mut bmp = Vec::new(); diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index 348a29f70..cc3fd3c7b 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -27,3 +27,4 @@ hayro-syntax = "0.7.2" hayro-interpret = { version = "0.7.0", default-features = false, features = ["embed-fonts"] } hayro-cmap = "0.1" kurbo = { workspace = true } +tiff = { workspace = true } diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index a4e60c3c9..dc776d22f 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -1171,6 +1171,18 @@ impl Exporter<'_> { let fmt = image::guess_format(&data).ok(); let img = match fmt { Some(image::ImageFormat::Jpeg) => Image::from_jpeg(data.clone().into(), true).ok(), + // A CMYK TIFF keeps its ink values (`image` decodes it to RGB, and 100% K would print + // as four-colour black). PDF/A exports are RGB only. + Some(image::ImageFormat::Tiff) if !self.rgb_only && designcraft_images::is_cmyk_tiff(&data) => match cmyk_tiff(&data) { + Some(img) => Some(img), + None => { + self.warn(format!( + "{}: converted to RGB (a CMYK TIFF that is planar, has premultiplied alpha, uses other inks or is over 256 MiB)", + asset.name + )); + lossless(&data, fmt) + } + }, _ if self.opts.compress_images => recompress(&data).or_else(|| lossless(&data, fmt)), _ => lossless(&data, fmt), }; @@ -1281,6 +1293,45 @@ fn lossless(data: &Arc>, fmt: Option) -> Option); + +impl krilla::image::CustomImage for CmykImage { + fn color_channel(&self) -> &[u8] { + &self.0.cmyk + } + fn alpha_channel(&self) -> Option<&[u8]> { + self.0.alpha.as_deref() + } + fn bits_per_component(&self) -> krilla::image::BitsPerComponent { + krilla::image::BitsPerComponent::Eight + } + fn size(&self) -> (u32, u32) { + (self.0.width, self.0.height) + } + fn icc_profile(&self) -> Option<&[u8]> { + None + } + fn color_space(&self) -> krilla::image::ImageColorspace { + krilla::image::ImageColorspace::Cmyk + } +} + +/// A CMYK TIFF as a DeviceCMYK image. `None` when it can't be read as CMYK. +fn cmyk_tiff(data: &[u8]) -> Option { + let r = designcraft_images::decode_cmyk_tiff(data)?; + // krilla panics when the channel lengths don't match the size. + let pixels = usize::try_from(r.width).ok()?.checked_mul(usize::try_from(r.height).ok()?)?; + if r.cmyk.len() != pixels.checked_mul(4)? || r.alpha.as_ref().is_some_and(|a| a.len() != pixels) { + return None; + } + Image::from_custom(CmykImage(Arc::new(r)), true).ok() +} + /// Opaque raster → JPEG (quality 90). `None` when the image has transparency or can't be decoded. fn recompress(data: &[u8]) -> Option { let img = image::load_from_memory(data).ok()?; diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 82ce3c821..90a407d74 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -201,3 +201,72 @@ fn tables_export_cell_text() { assert!(text.contains(w), "{w} missing from {text:?}"); } } + +/// A 4×2 CMYK TIFF: the left half 100% K (text in a scan), the right half C0 M100 Y100 K0. +fn cmyk_tiff() -> (Vec, Vec) { + let (k, red) = ([0u8, 0, 0, 255], [0u8, 255, 255, 0]); + let px: Vec = (0..2).flat_map(|_| [k, k, red, red]).flatten().collect(); + let mut b = Vec::new(); + tiff::encoder::TiffEncoder::new(std::io::Cursor::new(&mut b)).unwrap().write_image::(4, 2, &px).unwrap(); + (b, px) +} + +fn place(d: &mut Document, data: Vec, px: (u32, u32)) { + let asset = designcraft_doc::AssetId(d.alloc()); + let mime = designcraft_images::mime(&data).into(); + d.assets.insert( + asset, + std::sync::Arc::new(designcraft_doc::Asset { + id: asset, + name: "scan.tif".into(), + mime, + link: None, + data: data.into(), + pixels: Some(px), + page: 0, + }), + ); + let lid = d.default_layer(); + let id = ItemId(d.alloc()); + let mut it = Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(72.0, 72.0, 272.0, 172.0))); + it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + asset, + size: (px.0 as f64, px.1 as f64), + xf: designcraft_geom::Affine::translate((72.0, 72.0)) * designcraft_geom::Affine::scale(50.0), + auto_fit: designcraft_doc::Fitting::FillProportionally, + fit_align: 4, + crop: [0.0; 4], + }); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); +} + +/// Every image XObject in a PDF: its /ColorSpace name (`?` when not a name) and decoded samples. +fn image_xobjects(bytes: &[u8]) -> Vec<(String, Vec)> { + use hayro_syntax::object::Name; + let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).expect("parse"); + pdf.objects() + .into_iter() + .filter_map(|o| o.into_stream()) + .filter(|s| s.dict().get::("Subtype").is_some_and(|n| n.as_str() == "Image")) + .map(|s| { + let cs = s.dict().get::("ColorSpace").map_or("?".to_string(), |n| n.as_str().to_string()); + (cs, s.decoded().expect("decode").into_owned()) + }) + .collect() +} + +/// A placed CMYK TIFF went into PDFs as DeviceRGB (#35): 100% K became RGB black, which the +/// press separates as four-colour black. +#[test] +fn cmyk_tiff_keeps_its_inks() { + let (tif, px) = cmyk_tiff(); + let mut d = doc_with_text("x"); + place(&mut d, tif, (4, 2)); + for standard in [Standard::None, Standard::PdfX4] { + let r = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { standard, ..Default::default() }).unwrap(); + assert!(r.warnings.is_empty(), "{standard:?}: {:?}", r.warnings); + let images = image_xobjects(&r.bytes); + assert_eq!(images, vec![("DeviceCMYK".to_string(), px.clone())], "{standard:?}"); + assert_eq!(images[0].1[..4], [0, 0, 0, 255], "K only"); + } +}