mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-14 19:27:07 -04:00
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:
1 parent
e8d6cedfea
commit
16694c8f3c
7 files changed
+239
No files matched your search
Generated
+2
@@ -1018,6 +1018,7 @@ dependencies = [
|
||||
"image",
|
||||
"psd",
|
||||
"resvg",
|
||||
"tiff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1052,6 +1053,7 @@ dependencies = [
|
||||
"kurbo",
|
||||
"log",
|
||||
"thiserror 2.0.21",
|
||||
"tiff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -11,5 +11,6 @@ workspace = true
|
||||
[dependencies]
|
||||
designcraft-fonts = { workspace = true }
|
||||
image = { workspace = true }
|
||||
tiff = { workspace = true }
|
||||
resvg = { workspace = true }
|
||||
psd = { workspace = true }
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user