mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 10:47:11 -06:00
A PDF's DeviceCMYK image opened as RGB (the interpreter's pixels as a PNG), so every export wrote it as DeviceRGB or ICC RGB, silently. Images now keep their CMYK samples, as CMYK colours already did: - Model: CMYK images are blobs holding CMYK samples, a four-component JPEG (Adobe's inverted samples) or a Deflate CMYK TIFF. vectorcraft-doc's cmyk module reads their ink amounts (ImageBlob::is_cmyk, ::cmyk, capped at 64 megapixels) and writes the TIFF (ImageBlob::cmyk_tiff). - PDF import: DeviceCMYK, CalCMYK and four-component ICC images keep their samples. A JPEG with Decode [1 0 1 0 1 0 1 0] is kept byte for byte; any other is decoded (8 or 16 bits, its Decode array applied) and stored as a CMYK TIFF. Masked CMYK images and 1/2/4-bit ones open in RGB with a warning. Placing a CMYK TIFF keeps it CMYK too. - PDF export: CMYK images are written in CMYK (DeviceCMYK, or ICC-based with the CMYK profile when colours are tagged: PDF/X-3, PDF/X-4, PDF/A). A CMYK JPEG neither resampled nor recompressed passes through unchanged; others are resampled in CMYK and compressed as ZIP or as a CMYK JPEG (jpeg-encoder, which also replaces the ZIP fallback and its warning for images converted to CMYK). Output conversion treats them like CMYK colours: Preserve Numbers and PDF/X-1a keep their numbers, Convert to Destination converts them to another CMYK profile or to RGB through a 17^4 lookup table (cms::CmykLut, now also behind the renderer's ink table). A CMYK image that can't be decoded is reported in the warnings. - Canvas: CMYK images show through the working CMYK profile, as CMYK fills do, and paint their own inks in ink planes (CMYK raster exports, CMYK blending). SVG export writes TIFF images as PNG. Illustrator's Output › No Conversion keeps colour data as it is (https://helpx.adobe.com/illustrator/using/pdf-options.html). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
94 lines
4.1 KiB
Rust
94 lines
4.1 KiB
Rust
//! CMYK images keep their samples, as CMYK colours do: DeviceCMYK images and ICC-based ones with
|
||
//! four components. A JPEG whose Decode array inverts its samples (Adobe's CMYK JPEGs, the
|
||
//! convention JPEG readers and the PDF writer take a CMYK JPEG to follow) is kept as it is; any
|
||
//! other is decoded, its Decode array applied, and stored as a CMYK TIFF
|
||
//! ([`ImageBlob::cmyk_tiff`]). Every other image is read as RGB by the interpreter.
|
||
|
||
use hayro_syntax::Filter;
|
||
use hayro_syntax::object::stream::ImageDecodeParams;
|
||
use hayro_syntax::object::{Array, Dict, Name, Stream};
|
||
use vectorcraft_doc::ImageBlob;
|
||
use vectorcraft_doc::cmyk::{Inks, MAX_CMYK_PIXELS, jpeg_components};
|
||
|
||
/// A CMYK image read as RGB: why.
|
||
pub(crate) const MASKED: &str = "CMYK images with a mask were imported in RGB";
|
||
pub(crate) const UNREAD: &str = "CMYK images whose samples couldn't be read in CMYK were imported in RGB";
|
||
|
||
/// The Decode array of a JPEG kept as it is.
|
||
const INVERTED: [(f32, f32); 4] = [(1.0, 0.0); 4];
|
||
|
||
/// `key`, or its inline-image abbreviation `short` when `d` doesn't have it.
|
||
fn key<'k>(d: &Dict<'_>, key: &'k [u8], short: &'k [u8]) -> &'k [u8] {
|
||
if d.contains_key(key) { key } else { short }
|
||
}
|
||
|
||
/// Are the image's colours CMYK (DeviceCMYK, CalCMYK, or ICC-based with four components)?
|
||
fn cmyk_space(d: &Dict<'_>) -> bool {
|
||
let cs = key(d, b"ColorSpace", b"CS");
|
||
if let Some(n) = d.get::<Name<'_>>(cs) {
|
||
return matches!(n.as_ref(), b"DeviceCMYK" | b"CMYK");
|
||
}
|
||
let Some(a) = d.get::<Array<'_>>(cs) else { return false };
|
||
let mut it = a.flex_iter();
|
||
match it.next::<Name<'_>>() {
|
||
Some(n) if n.as_ref() == b"CalCMYK" => true,
|
||
Some(n) if n.as_ref() == b"ICCBased" => it.next::<Stream<'_>>().and_then(|s| s.dict().get::<u8>(b"N")) == Some(4),
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
/// Each component's Decode range (`[0 1]` each when the array is missing or malformed).
|
||
fn decode_ranges(d: &Dict<'_>) -> [(f32, f32); 4] {
|
||
let mut out = [(0.0, 1.0); 4];
|
||
let Some(a) = d.get::<Array<'_>>(key(d, b"Decode", b"D")) else { return out };
|
||
let v: Vec<f32> = a.iter::<f32>().collect();
|
||
if v.len() == 8 && v.iter().all(|x| x.is_finite()) {
|
||
for (o, p) in out.iter_mut().zip(v.as_chunks::<2>().0) {
|
||
*o = (p[0], p[1]);
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// The image `st` (`width` × `height` pixels) in CMYK: `Ok(None)` when it isn't a CMYK image,
|
||
/// `Err` (a warning) when it is one that is read as RGB.
|
||
pub(crate) fn cmyk(st: &Stream<'_>, width: u32, height: u32) -> Result<Option<ImageBlob>, &'static str> {
|
||
let d = st.dict();
|
||
if !cmyk_space(d) {
|
||
return Ok(None);
|
||
}
|
||
if d.contains_key(b"SMask") || d.contains_key(b"Mask") {
|
||
return Err(MASKED);
|
||
}
|
||
let ranges = decode_ranges(d);
|
||
let filters = st.filters();
|
||
if ranges == INVERTED && matches!(filters.as_slice(), [Filter::DctDecode]) {
|
||
let raw = st.raw_data();
|
||
if jpeg_components(&raw) == Some(4) {
|
||
return Ok(Some(ImageBlob::new("image/jpeg", raw.into_owned())));
|
||
}
|
||
}
|
||
let bpc = d.get::<u8>(key(d, b"BitsPerComponent", b"BPC")).unwrap_or(8);
|
||
if !matches!(bpc, 8 | 16) {
|
||
return Err(UNREAD);
|
||
}
|
||
let n = width as u64 * height as u64;
|
||
if n == 0 || n > MAX_CMYK_PIXELS {
|
||
return Err(UNREAD);
|
||
}
|
||
let size = usize::from(bpc / 8);
|
||
let params = ImageDecodeParams { is_indexed: false, bpc: Some(bpc), num_components: Some(4), target_dimension: None, width, height };
|
||
let Ok(decoded) = st.decoded_image(¶ms) else { return Err(UNREAD) };
|
||
let Some(raw) = decoded.data.get(..n as usize * 4 * size) else { return Err(UNREAD) };
|
||
let value = |s: &[u8]| match *s {
|
||
[hi, lo] => u16::from_be_bytes([hi, lo]) as f32 / 65535.0,
|
||
[v] => v as f32 / 255.0,
|
||
_ => 0.0,
|
||
};
|
||
let inks = raw
|
||
.chunks_exact(size * 4)
|
||
.flat_map(|px| px.chunks_exact(size).zip(ranges).map(|(s, (lo, hi))| ((lo + value(s) * (hi - lo)).clamp(0.0, 1.0) * 255.0).round() as u8))
|
||
.collect();
|
||
Inks::new(width, height, inks).and_then(|i| ImageBlob::cmyk_tiff(&i)).map(Some).ok_or(UNREAD)
|
||
}
|