PDF: placed CMYK TIFFs keep their inks

A CMYK TIFF went into every PDF, PDF/X-4 included, as a DeviceRGB image:
load_image sent anything that wasn't JPEG, PNG, GIF or WebP through
designcraft_images::decode_rgba, and `image` decodes CMYK TIFFs to RGB. 100%
K came out as RGB black, which the press separates as four-colour black
(#35).

designcraft-images now reads a CMYK TIFF's ink values with the tiff crate
(the version `image` already uses): 8- or 16-bit, interleaved, opaque or
with unassociated alpha. PDF export writes them through krilla's CustomImage
as DeviceCMYK, as it does a CMYK JPEG, and leaves out the TIFF's own ICC
profile so the numbers go to the output intent's press unchanged. PDF/A
exports stay RGB. A CMYK TIFF this can't read (planar, premultiplied alpha,
another ink set, over 256 MiB) is still converted, now with a warning. The
screen keeps decoding to RGB.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
EthanSuquetandClaude Opus 5.5 committed 2026-10-06 15:15:09 -04:00
1 parent e8d6cedfea
commit 16694c8f3c
7 files changed
+239

No files matched your search

Generated
+2
View File
@@ -1018,6 +1018,7 @@ dependencies = [
"image",
"psd",
"resvg",
"tiff",
]
[[package]]
@@ -1052,6 +1053,7 @@ dependencies = [
"kurbo",
"log",
"thiserror 2.0.21",
"tiff",
]
[[package]]
+2
View File
@@ -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"
+1
View File
@@ -11,5 +11,6 @@ workspace = true
[dependencies]
designcraft-fonts = { workspace = true }
image = { workspace = true }
tiff = { workspace = true }
resvg = { workspace = true }
psd = { workspace = true }
+113
View File
@@ -105,6 +105,77 @@ pub fn decode_rgba(bytes: &[u8]) -> Option<image::RgbaImage> {
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<u8>,
/// Straight alpha, `width * height` bytes; `None` when the image is opaque.
pub alpha: Option<Vec<u8>>,
}
fn tiff_decoder(bytes: &[u8]) -> Option<tiff::decoder::Decoder<std::io::Cursor<&[u8]>>> {
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<CmykRaster> {
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::<u16>(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::<u16>(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<u8>, 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<C: tiff::encoder::colortype::ColorType>(w: u32, h: u32, px: &[C::Inner], alpha: bool) -> Vec<u8>
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::<C>(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::<CMYK8>(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::<CMYK16>(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::<CMYKA8>(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::<RGB8>(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();
+1
View File
@@ -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 }
+51
View File
@@ -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<Vec<u8>>, fmt: Option<image::ImageFormat>) -> Option<Imag
})
}
/// A CMYK raster as a krilla image: its samples are written unchanged as DeviceCMYK, like a
/// CMYK JPEG's. An embedded ICC profile is left out, as it is for JPEGs: the inks are meant for
/// the output intent's press, and tagging them with another CMYK profile would let a RIP
/// convert 100% K to four-colour black.
#[derive(Clone, Hash)]
struct CmykImage(Arc<designcraft_images::CmykRaster>);
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<Image> {
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<Image> {
let img = image::load_from_memory(data).ok()?;
+69
View File
@@ -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<u8>, Vec<u8>) {
let (k, red) = ([0u8, 0, 0, 255], [0u8, 255, 255, 0]);
let px: Vec<u8> = (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::<tiff::encoder::colortype::CMYK8>(4, 2, &px).unwrap();
(b, px)
}
fn place(d: &mut Document, data: Vec<u8>, 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<u8>)> {
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::<Name>("Subtype").is_some_and(|n| n.as_str() == "Image"))
.map(|s| {
let cs = s.dict().get::<Name>("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");
}
}