diff --git a/Cargo.lock b/Cargo.lock index 1f9812548..acdc0e370 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1081,8 +1081,12 @@ dependencies = [ "designcraft-color", "designcraft-fonts", "image", + "log", + "moxcms", "resvg", "tiff", + "zune-core", + "zune-jpeg", ] [[package]] diff --git a/ROADMAP.md b/ROADMAP.md index 538650941..b22996ea0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,6 +128,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · Placed photos show in their own colours: embedded ICC profiles (Adobe RGB, ProPhoto, P3, grey) are converted to sRGB on screen, and CMYK JPEGs (including YCCK), CMYK TIFFs and CMYK PSDs go through their embedded CMYK profile or the working CMYK space with the colour settings' intent and black-point compensation, instead of the profile-free formula. Unreadable profiles are ignored. No readiness percentage change. - **2026-10-12** · Placed Word documents keep their bullets and numbering (#626): `numbering.xml` levels become bullet or numbered paragraphs with Word's number formats, label text, start numbers, restarts and list indents, also when a paragraph style carries the list. No readiness percentage change. - **2026-10-12** · Text strokes can sit behind the fill: the stroke lies wholly outside the letters at its full weight on screen, in PNG and in PDF, set from the Properties panel or Character Style Options. IDML `StrokeAlignment` on text imports and exports as this setting, so imported text with outside-aligned strokes keeps its letter shapes (#619). No readiness percentage change. - **2026-10-12** · Type tool: Option/Alt+Left/Right kerns the pair at the caret (manual kerning) or tracks a selection by the Kerning/Tracking increment (Preferences › Units & Increments, 20/1000 em), five times with Cmd/Ctrl; moving by words is Cmd+Left/Right on macOS and Ctrl+Left/Right elsewhere, and Option/Alt+Shift+Left/Right still selects by words (#621). No readiness percentage change. diff --git a/crates/color/src/cms/icc.rs b/crates/color/src/cms/icc.rs index 565ae0b70..bf8b2e633 100644 --- a/crates/color/src/cms/icc.rs +++ b/crates/color/src/cms/icc.rs @@ -66,6 +66,11 @@ impl IccProfile { Self::from_profile(name, p) } + /// The parsed profile. + pub fn profile(&self) -> &ColorProfile { + &self.profile + } + fn layout(&self) -> Layout { match self.kind { ProfileKind::Cmyk => Layout::Rgba, // 4 channels diff --git a/crates/color/src/cms/mod.rs b/crates/color/src/cms/mod.rs index 2294d764e..35078426f 100644 --- a/crates/color/src/cms/mod.rs +++ b/crates/color/src/cms/mod.rs @@ -14,7 +14,7 @@ pub mod icc; pub mod icc_out; pub mod lab; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, OnceLock, RwLock}; use serde::{Deserialize, Serialize}; @@ -227,6 +227,7 @@ pub fn register_icc(bytes: &[u8], name: Option) -> Result Option { + match &self.cmyk { + CmykSpace::Generic(_) => { + static P: OnceLock> = OnceLock::new(); + P.get_or_init(|| moxcms::ColorProfile::new_from_slice(icc_out::generic_cmyk_icc()).ok()).clone() + } + CmykSpace::Device => None, + CmykSpace::Icc(p) => Some(p.profile().clone()), + } + } + /// Working RGB → display sRGB. pub fn rgb_to_srgb(&self, rgb: [f32; 3]) -> [f32; 3] { match &self.rgb { @@ -604,6 +618,13 @@ impl ProofLut { static ACTIVE: RwLock>> = RwLock::new(None); static RGB_IS_SRGB: AtomicBool = AtomicBool::new(true); static CMYK_IS_DEVICE: AtomicBool = AtomicBool::new(false); +static GENERATION: AtomicU64 = AtomicU64::new(0); + +/// A number that changes whenever the active settings change or a profile is registered: caches +/// of colour-converted pixels compare it to know when to convert again. +pub fn generation() -> u64 { + GENERATION.load(Ordering::Relaxed) +} /// The active colour-management context (Edit → Color Settings). pub fn active() -> Arc { @@ -621,9 +642,13 @@ pub fn active_settings() -> ColorSettings { /// Replace the active settings (fails, leaving them unchanged, if a profile is unknown). pub fn set_active(settings: &ColorSettings) -> Result<(), CmsError> { let cms = Cms::new(settings)?; + let changed = active().settings != *settings; RGB_IS_SRGB.store(cms.rgb_is_srgb(), Ordering::Relaxed); CMYK_IS_DEVICE.store(matches!(cms.cmyk, CmykSpace::Device), Ordering::Relaxed); *ACTIVE.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(cms)); + if changed { + GENERATION.fetch_add(1, Ordering::Relaxed); + } Ok(()) } diff --git a/crates/images/Cargo.toml b/crates/images/Cargo.toml index 0db9d190c..3a2893250 100644 --- a/crates/images/Cargo.toml +++ b/crates/images/Cargo.toml @@ -14,6 +14,10 @@ designcraft-fonts = { workspace = true } image = { workspace = true } tiff = { workspace = true } resvg = { workspace = true } +log = { workspace = true } +moxcms = "0.8" +zune-core = { version = "0.5", default-features = false } +zune-jpeg = "0.5" [dev-dependencies] designcraft-fonts = { workspace = true, features = ["testing"] } diff --git a/crates/images/src/colour.rs b/crates/images/src/colour.rs new file mode 100644 index 000000000..365d97909 --- /dev/null +++ b/crates/images/src/colour.rs @@ -0,0 +1,425 @@ +//! Display colour of placed rasters. Pixels with an embedded ICC profile are converted to sRGB; +//! CMYK pixels go through their own profile, else the working CMYK space. Every raster decode +//! path (JPEG, PNG, TIFF, WebP, PSD) converts here, once, when the image is decoded. +//! +//! [`Profile`], [`Transform`], [`TransformOptions`], [`Intent`] and [`Builtin`] follow the names of +//! the shared craft-cms crate, so moving to it changes this module only. Underneath they are thin +//! wrappers over `moxcms`. +//! +//! Profiles are untrusted input: one that is too large, can't be parsed, doesn't match the +//! pixels or yields no transform is ignored with a log line, and the pixels are shown as before +//! (RGB and grey as sRGB, CMYK through the working space). + +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::sync::{Arc, Mutex, PoisonError}; + +use designcraft_color::{Color, cms::Cms}; +use image::RgbaImage; +use moxcms::{ColorProfile, DataColorSpace, Layout, RenderingIntent, Transform8BitExecutor}; + +pub use designcraft_color::cms::Intent; + +use crate::CmykRaster; + +/// Embedded profiles larger than this are ignored. +pub const MAX_ICC_BYTES: usize = 16 << 20; + +/// Pixels per transform call. +const CHUNK: usize = 4096; + +/// Transforms kept by [`Transform::cached`]. +const CACHED_TRANSFORMS: usize = 16; + +/// Why a profile or transform can't be used. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProfileError(String); + +impl std::fmt::Display for ProfileError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for ProfileError {} + +/// Profiles built into the code. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Builtin { + Srgb, + AdobeRgb, + DisplayP3, + ProPhotoRgb, +} + +/// The colour space a profile's device values are in. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Space { + Gray, + Rgb, + Cmyk, + Other, +} + +impl Space { + fn channels(self) -> usize { + match self { + Space::Gray => 1, + Space::Rgb => 3, + Space::Cmyk | Space::Other => 4, + } + } + /// moxcms reads four-channel CMYK through its RGBA layout. + fn layout(self) -> Layout { + match self { + Space::Gray => Layout::Gray, + Space::Rgb => Layout::Rgb, + Space::Cmyk | Space::Other => Layout::Rgba, + } + } +} + +/// An ICC profile. +#[derive(Clone, Debug)] +pub struct Profile { + inner: ColorProfile, + builtin: Option, + /// Identifies the profile for [`Transform::cached`]; `None` when it can't be encoded. + key: Option, +} + +fn hash(bytes: &[u8]) -> u64 { + let mut h = DefaultHasher::new(); + bytes.hash(&mut h); + h.finish() +} + +impl Profile { + /// Parse ICC bytes (at most [`MAX_ICC_BYTES`]). + pub fn parse(bytes: &[u8]) -> Result { + if bytes.len() > MAX_ICC_BYTES { + return Err(ProfileError(format!("ICC profile of {} bytes is too large", bytes.len()))); + } + let inner = ColorProfile::new_from_slice(bytes).map_err(|e| ProfileError(format!("ICC profile can't be read ({e:?})")))?; + Ok(Self { inner, builtin: None, key: Some(hash(bytes)) }) + } + + pub fn builtin(b: Builtin) -> Self { + let inner = match b { + Builtin::Srgb => ColorProfile::new_srgb(), + Builtin::AdobeRgb => ColorProfile::new_adobe_rgb(), + Builtin::DisplayP3 => ColorProfile::new_display_p3(), + Builtin::ProPhotoRgb => ColorProfile::new_pro_photo_rgb(), + }; + let mut h = DefaultHasher::new(); + b.hash(&mut h); + Self { inner, builtin: Some(b), key: Some(h.finish()) } + } + + /// A profile the colour crate already parsed or built. + fn from_moxcms(inner: ColorProfile) -> Self { + let key = inner.encode().ok().map(|b| hash(&b)); + Self { inner, builtin: None, key } + } + + pub fn space(&self) -> Space { + match self.inner.color_space { + DataColorSpace::Gray => Space::Gray, + DataColorSpace::Rgb => Space::Rgb, + DataColorSpace::Cmyk => Space::Cmyk, + _ => Space::Other, + } + } +} + +/// How a [`Transform`] maps colours. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct TransformOptions { + pub intent: Intent, + /// Scale the source's black to the destination's (relative colorimetric only). + pub black_point_compensation: bool, +} + +/// A colour transform between two profiles, for interleaved 8-bit pixels. +pub struct Transform { + exec: Arc, + src_channels: usize, + dst_channels: usize, + /// Black point compensation, applied to the sRGB output. + bpc: Option>, +} + +fn rendering_intent(i: Intent) -> RenderingIntent { + match i { + Intent::Perceptual => RenderingIntent::Perceptual, + Intent::RelativeColorimetric => RenderingIntent::RelativeColorimetric, + Intent::Saturation => RenderingIntent::Saturation, + Intent::AbsoluteColorimetric => RenderingIntent::AbsoluteColorimetric, + } +} + +fn srgb_to_linear(v: f32) -> f32 { + if v <= 0.040_45 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } +} + +fn linear_to_srgb(v: f32) -> f32 { + if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 } +} + +/// Black point compensation into sRGB as a table on the 8-bit output: linear light is rescaled +/// so the source's black (luminance `y_black`) lands on sRGB black and white stays white. sRGB +/// linear RGB is a linear map of XYZ, so this is the usual XYZ scaling for a neutral black. +fn bpc_table(src: &Profile, intent: RenderingIntent) -> Option> { + let candidates: &[f32] = match src.space() { + // Solid black ink, all inks, and a typical rich black: the darkest is the black point. + Space::Cmyk => &[0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.75, 0.68, 0.67, 0.9], + Space::Rgb => &[0.0; 3], + Space::Gray => &[0.0], + Space::Other => return None, + }; + let srgb = ColorProfile::new_srgb(); + let opts = moxcms::TransformOptions { rendering_intent: intent, ..Default::default() }; + let x = src.inner.create_transform_f32(src.space().layout(), &srgb, Layout::Rgb, opts).ok()?; + let mut out = vec![0f32; candidates.len() / src.space().channels() * 3]; + x.transform(candidates, &mut out).ok()?; + let y_black = out + .as_chunks::<3>() + .0 + .iter() + .map(|c| { + let [r, g, b] = c.map(|v| srgb_to_linear(v.clamp(0.0, 1.0))); + 0.2126 * r + 0.7152 * g + 0.0722 * b + }) + .fold(f32::INFINITY, f32::min); + if !(1e-4..0.5).contains(&y_black) { + return None; + } + let mut t = Box::new([0u8; 256]); + for (i, v) in t.iter_mut().enumerate() { + let lin = (srgb_to_linear(i as f32 / 255.0) - y_black) / (1.0 - y_black); + *v = (linear_to_srgb(lin.clamp(0.0, 1.0)) * 255.0).round() as u8; + } + Some(t) +} + +impl Transform { + pub fn new(src: &Profile, dst: &Profile, intent: Intent) -> Result { + Self::with_options(src, dst, TransformOptions { intent, black_point_compensation: false }) + } + + pub fn with_options(src: &Profile, dst: &Profile, options: TransformOptions) -> Result { + let intent = rendering_intent(options.intent); + let opts = moxcms::TransformOptions { rendering_intent: intent, ..Default::default() }; + let exec = src + .inner + .create_transform_8bit(src.space().layout(), &dst.inner, dst.space().layout(), opts) + .map_err(|e| ProfileError(format!("no transform between the profiles ({e:?})")))?; + // Black point compensation is implemented for an sRGB destination (the display). + let bpc = (options.black_point_compensation && options.intent == Intent::RelativeColorimetric && dst.builtin == Some(Builtin::Srgb)) + .then(|| bpc_table(src, intent)) + .flatten(); + Ok(Self { exec, src_channels: src.space().channels(), dst_channels: dst.space().channels(), bpc }) + } + + /// [`Transform::with_options`], reusing a transform built earlier for the same profiles and + /// options (CMYK transforms build a large table). + pub fn cached(src: &Profile, dst: &Profile, options: TransformOptions) -> Result, ProfileError> { + static CACHE: Mutex)>> = Mutex::new(Vec::new()); + let key = src.key.zip(dst.key).map(|(s, d)| (s, d, options)); + if let Some(key) = key + && let Some((_, t)) = CACHE.lock().unwrap_or_else(PoisonError::into_inner).iter().find(|(k, _)| *k == key) + { + return Ok(t.clone()); + } + let t = Arc::new(Self::with_options(src, dst, options)?); + if let Some(key) = key { + let mut c = CACHE.lock().unwrap_or_else(PoisonError::into_inner); + if c.len() >= CACHED_TRANSFORMS { + c.remove(0); + } + c.push((key, t.clone())); + } + Ok(t) + } + + /// Convert interleaved pixels: `src` in the source's channels, `dst` the same number of + /// pixels in the destination's. + pub fn convert_u8(&self, src: &[u8], dst: &mut [u8]) -> Result<(), ProfileError> { + let n = src.len() / self.src_channels; + if !src.len().is_multiple_of(self.src_channels) || Some(dst.len()) != n.checked_mul(self.dst_channels) { + return Err(ProfileError("pixel buffers don't match the transform".into())); + } + self.exec.transform(src, dst).map_err(|e| ProfileError(format!("{e:?}")))?; + if let Some(t) = &self.bpc { + for v in dst.iter_mut() { + *v = t[usize::from(*v)]; + } + } + Ok(()) + } +} + +/// Decoded 8-bit pixels and what their values mean. +pub enum Pixels { + /// RGB with straight alpha; converted in place. + Rgb(RgbaImage), + /// Grey, each pixel `[g, g, g, a]`; converted in place. + Gray(RgbaImage), + /// Ink values (0 = no ink). + Cmyk(CmykRaster), +} + +/// How images are shown: the CMYK profile for CMYK pixels without a usable profile of their own +/// (`None`: the profile-free formula, for Device CMYK), and the transform options. +#[derive(Clone, Debug)] +pub struct DisplayColour { + working_cmyk: Option, + options: TransformOptions, +} + +impl DisplayColour { + pub fn new(working_cmyk: Option, options: TransformOptions) -> Self { + Self { working_cmyk, options } + } + + /// The active colour settings (Edit → Color Settings): working CMYK space, intent and black + /// point compensation. + pub fn active() -> Self { + Self::of(&designcraft_color::cms::active()) + } + + pub fn of(cms: &Cms) -> Self { + let s = cms.settings(); + Self::new(cms.cmyk_icc_profile().map(Profile::from_moxcms), TransformOptions { intent: s.intent, black_point_compensation: s.bpc }) + } +} + +/// Straight RGBA8 in display sRGB. `None` only when a CMYK raster's buffers don't match its size +/// or the output can't be allocated. +pub fn to_display_rgba(px: Pixels, embedded_icc: Option<&[u8]>, display: &DisplayColour) -> Option { + let own = |space| embedded_icc.and_then(|icc| to_srgb(&parse(icc, space)?, display.options)); + match px { + Pixels::Rgb(mut img) => { + if let Some(x) = own(Space::Rgb) + && !keeps_rgb(&x) + { + in_place(&mut img, &x, 3); + } + Some(img) + } + Pixels::Gray(mut img) => { + if let Some(x) = own(Space::Gray) + && !keeps_gray(&x) + { + in_place(&mut img, &x, 1); + } + Some(img) + } + Pixels::Cmyk(raster) => { + let xf = own(Space::Cmyk).or_else(|| to_srgb(display.working_cmyk.as_ref()?, display.options)); + cmyk(&raster, xf.as_deref()) + } + } +} + +fn parse(icc: &[u8], pixels: Space) -> Option { + match Profile::parse(icc) { + Ok(p) if p.space() == pixels => Some(p), + Ok(p) => { + log::warn!("embedded ICC profile is for {:?}, the pixels are {pixels:?}: ignored", p.space()); + None + } + Err(e) => { + log::warn!("embedded {e}: ignored"); + None + } + } +} + +fn to_srgb(src: &Profile, options: TransformOptions) -> Option> { + match Transform::cached(src, &Profile::builtin(Builtin::Srgb), options) { + Ok(x) => Some(x), + Err(e) => { + log::warn!("image ICC profile to sRGB: {e}; profile ignored"); + None + } + } +} + +/// Whether an RGB transform leaves every value within 1 of where it was (an sRGB profile): the +/// pixels are then kept exactly as decoded. +fn keeps_rgb(x: &Transform) -> bool { + const V: [u8; 9] = [0, 32, 64, 96, 128, 160, 192, 224, 255]; + let src: Vec = V.iter().flat_map(|&r| V.iter().flat_map(move |&g| V.iter().flat_map(move |&b| [r, g, b]))).collect(); + let mut dst = vec![0u8; src.len()]; + x.convert_u8(&src, &mut dst).is_ok() && src.iter().zip(&dst).all(|(a, b)| a.abs_diff(*b) <= 1) +} + +fn keeps_gray(x: &Transform) -> bool { + let src: Vec = (0..=255).collect(); + let mut dst = vec![0u8; src.len() * 3]; + x.convert_u8(&src, &mut dst).is_ok() && src.iter().zip(dst.as_chunks::<3>().0).all(|(g, rgb)| rgb.iter().all(|v| v.abs_diff(*g) <= 1)) +} + +/// Convert RGBA pixels whose first `channels` (3: RGB, 1: grey) hold the source values. +fn in_place(img: &mut RgbaImage, x: &Transform, channels: usize) { + let mut src = Vec::with_capacity(CHUNK * channels); + let mut dst = vec![0u8; CHUNK * 3]; + for chunk in img.chunks_mut(CHUNK * 4) { + let px = chunk.as_chunks_mut::<4>().0; + src.clear(); + src.extend(px.iter().flat_map(|p| p.iter().take(channels))); + let Some(out) = dst.get_mut(..px.len() * 3) else { return }; + if x.convert_u8(&src, out).is_err() { + return; + } + for (p, rgb) in px.iter_mut().zip(out.as_chunks::<3>().0) { + p[..3].copy_from_slice(rgb); + } + } +} + +/// CMYK inks → RGBA through `x`, or the profile-free formula without one. +fn cmyk(r: &CmykRaster, x: Option<&Transform>) -> Option { + let pixels = usize::try_from(r.width).ok()?.checked_mul(usize::try_from(r.height).ok()?)?; + let len = pixels.checked_mul(4)?; + if r.cmyk.len() != len || r.alpha.as_ref().is_some_and(|a| a.len() != pixels) { + return None; + } + let mut rgba = Vec::new(); + rgba.try_reserve_exact(len).ok()?; + rgba.resize(len, 255); + + let mut converted = false; + if let Some(x) = x { + converted = true; + let mut dst = vec![0u8; CHUNK * 3]; + for (src, out) in r.cmyk.chunks(CHUNK * 4).zip(rgba.chunks_mut(CHUNK * 4)) { + let rgb = dst.get_mut(..src.len() / 4 * 3)?; + if let Err(e) = x.convert_u8(src, rgb) { + log::warn!("CMYK image conversion failed ({e}): shown without a profile"); + converted = false; + break; + } + for (p, c) in out.as_chunks_mut::<4>().0.iter_mut().zip(rgb.as_chunks::<3>().0) { + p[..3].copy_from_slice(c); + } + } + } + if !converted { + let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + let ink = |v: u8| f32::from(v) / 255.0; + for (p, &[c, m, y, k]) in rgba.as_chunks_mut::<4>().0.iter_mut().zip(r.cmyk.as_chunks::<4>().0) { + let [rr, g, b] = Color::cmyk(ink(c), ink(m), ink(y), ink(k)).to_rgb_uncalibrated(); + p[..3].copy_from_slice(&[q(rr), q(g), q(b)]); + } + } + if let Some(alpha) = &r.alpha { + for (p, &a) in rgba.as_chunks_mut::<4>().0.iter_mut().zip(alpha) { + p[3] = a; + } + } + RgbaImage::from_raw(r.width, r.height, rgba) +} + +#[cfg(test)] +mod tests; diff --git a/crates/images/src/colour/tests.rs b/crates/images/src/colour/tests.rs new file mode 100644 index 000000000..22d0d7f48 --- /dev/null +++ b/crates/images/src/colour/tests.rs @@ -0,0 +1,210 @@ +use designcraft_color::cms::{self, Cms, ColorSettings}; +use image::{ImageEncoder, Rgba}; +use moxcms::ColorProfile; + +use super::*; +use crate::decode_rgba; + +/// sRGB 0..255 of CMYK inks through the Generic CMYK model (relative colorimetric, no BPC). +fn generic_rgb(cmyk: [f32; 4]) -> [f32; 3] { + Cms::default().cmyk_to_srgb(cmyk, false).map(|v| v * 255.0) +} + +/// The default colour settings without black point compensation. +fn no_bpc() -> DisplayColour { + DisplayColour::of(&Cms::new(&ColorSettings { bpc: false, ..ColorSettings::default() }).unwrap()) +} + +/// One CMYK pixel (inks 0..255) as shown with `display`. +fn shown(cmyk: [u8; 4], display: &DisplayColour) -> [u8; 4] { + let raster = CmykRaster { width: 1, height: 1, cmyk: cmyk.to_vec(), alpha: None }; + to_display_rgba(Pixels::Cmyk(raster), None, display).unwrap().get_pixel(0, 0).0 +} + +fn near(p: [u8; 4], want: [f32; 3], tol: f32) -> bool { + p.iter().zip(want).all(|(&a, b)| (f32::from(a) - b).abs() <= tol) +} + +/// A baseline JPEG, one 8×8 block per component of four, every sample of component `i` equal to +/// `values[i]`; with an Adobe APP14 marker carrying `adobe` (its colour transform) when given. +fn solid_jpeg(values: [u8; 4], adobe: Option) -> Vec { + fn seg(j: &mut Vec, marker: u8, body: &[u8]) { + j.extend([0xFF, marker]); + j.extend(u16::try_from(body.len() + 2).unwrap().to_be_bytes()); + j.extend(body); + } + const DC_BITS: [u8; 16] = [0, 1, 5, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0]; + let mut j = vec![0xFF, 0xD8]; + if let Some(t) = adobe { + seg(&mut j, 0xEE, &[b"Adobe".as_slice(), &[0, 100, 0, 0, 0, 0, t]].concat()); + } + seg(&mut j, 0xDB, &[[0u8].as_slice(), &[1u8; 64]].concat()); + seg(&mut j, 0xC0, &[8, 0, 8, 0, 8, 4, 1, 0x11, 0, 2, 0x11, 0, 3, 0x11, 0, 4, 0x11, 0]); + seg(&mut j, 0xC4, &[[0x00u8].as_slice(), &DC_BITS, &(0u8..12).collect::>()].concat()); + let mut ac_bits = [0u8; 16]; + ac_bits[0] = 1; // one code, "0": end of block + seg(&mut j, 0xC4, &[[0x10u8].as_slice(), &ac_bits, &[0x00]].concat()); + seg(&mut j, 0xDA, &[4, 1, 0, 2, 0, 3, 0, 4, 0, 0, 63, 0]); + + // Canonical DC codes for categories 0..11. + let mut codes = Vec::new(); + let mut code = 0u32; + for (len, &n) in (1..=16u32).zip(&DC_BITS) { + for _ in 0..n { + codes.push((code, len)); + code += 1; + } + code <<= 1; + } + let mut bits: Vec = Vec::new(); + let mut put = |v: u32, n: u32| bits.extend((0..n).rev().map(|i| v >> i & 1 == 1)); + for v in values { + // DC of a flat block is 8 × the level-shifted sample (quantiser 1). + let diff = (i32::from(v) - 128) * 8; + let cat = 32 - diff.unsigned_abs().leading_zeros(); + let (c, len) = codes[cat as usize]; + put(c, len); + let extra = if diff >= 0 { diff } else { diff - 1 } & ((1 << cat) - 1); + put(extra as u32, cat); + put(0, 1); + } + while !bits.len().is_multiple_of(8) { + bits.push(true); + } + for byte in bits.chunks(8).map(|b| b.iter().fold(0u8, |acc, &x| acc << 1 | u8::from(x))) { + j.push(byte); + if byte == 0xFF { + j.push(0); + } + } + j.extend([0xFF, 0xD9]); + j +} + +/// A PNG of `px` (RGBA) with `icc` embedded. +fn png_with_icc(w: u32, h: u32, px: &[u8], icc: Option>) -> Vec { + let mut b = Vec::new(); + let mut enc = image::codecs::png::PngEncoder::new(&mut b); + if let Some(icc) = icc { + enc.set_icc_profile(icc).unwrap(); + } + enc.write_image(px, w, h, image::ExtendedColorType::Rgba8).unwrap(); + b +} + +fn adobe_rgb_icc() -> Vec { + ColorProfile::new_adobe_rgb().encode().unwrap() +} + +/// sRGB 0..255 of an Adobe RGB colour through the colour crate's float transform. +fn adobe_to_srgb(rgb: [u8; 3]) -> [f32; 3] { + let cms = Cms::new(&ColorSettings { rgb: cms::ADOBE_RGB.into(), ..ColorSettings::default() }).unwrap(); + cms.rgb_to_srgb(rgb.map(|v| f32::from(v) / 255.0)).map(|v| v * 255.0) +} + +#[test] +fn cmyk_jpeg_goes_through_the_working_cmyk_space() { + // Adobe CMYK is stored inverted: cyan 100 % is C 0, M Y K 255. + let jpeg = solid_jpeg([0, 255, 255, 255], Some(0)); + let p = decode_rgba(&jpeg).unwrap().get_pixel(3, 3).0; + let cyan = shown([255, 0, 0, 0], &DisplayColour::active()); + assert_eq!(p, cyan); + // Not the profile-free (0, 255, 255): press cyan is a duller, darker blue. + assert!(p[1] < 200 && p[2] < 250 && p[3] == 255, "{p:?}"); + + // YCCK (Adobe transform 2) of the same inks: YCbCr of (255, 0, 0), K stored 255. + let ycck = solid_jpeg([76, 85, 255, 255], Some(2)); + let q = decode_rgba(&ycck).unwrap().get_pixel(0, 0).0; + let want = cyan.map(f32::from); + assert!(near(q, [want[0], want[1], want[2]], 6.0), "{q:?} vs {cyan:?}"); + + // Without the Adobe marker the values are ink amounts as stored. + let plain = solid_jpeg([255, 0, 0, 0], None); + assert_eq!(decode_rgba(&plain).unwrap().get_pixel(7, 7).0, cyan); +} + +#[test] +fn working_cmyk_follows_the_model_and_black_point_compensation() { + // The working space's ICC table agrees with the Generic CMYK model itself. + for inks in [[255, 0, 0, 0], [0, 255, 0, 0], [0, 0, 255, 0], [0, 0, 0, 255], [64, 32, 160, 20]] { + let p = shown(inks, &no_bpc()); + let want = generic_rgb(inks.map(|v| f32::from(v) / 255.0)); + // Within the 9-point table and 8-bit rounding. + assert!(near(p, want, 6.0), "{inks:?}: {p:?} vs {want:?}"); + } + // With black point compensation the press's darkest black is display black, paper stays + // white, and solid K is darker than without it. + let with = DisplayColour::active(); + let rich = shown([255, 255, 255, 255], &with); + assert!(rich[..3].iter().all(|&v| v <= 3), "{rich:?}"); + assert_eq!(shown([0, 0, 0, 0], &with), shown([0, 0, 0, 0], &no_bpc())); + let (k, k_plain) = (shown([0, 0, 0, 255], &with), shown([0, 0, 0, 255], &no_bpc())); + assert!(k[1] < k_plain[1], "{k:?} vs {k_plain:?}"); +} + +#[test] +fn cmyk_uses_its_own_profile_before_the_working_space() { + let raster = || CmykRaster { width: 2, height: 1, cmyk: vec![255, 0, 0, 0, 0, 0, 0, 255], alpha: Some(vec![255, 128]) }; + let device = DisplayColour::of(&Cms::new(&ColorSettings { cmyk: cms::DEVICE_CMYK.into(), ..ColorSettings::default() }).unwrap()); + // Device CMYK working space: the profile-free formula. + let img = to_display_rgba(Pixels::Cmyk(raster()), None, &device).unwrap(); + assert_eq!(img.get_pixel(0, 0).0, [0, 255, 255, 255]); + assert_eq!(img.get_pixel(1, 0).0, [0, 0, 0, 128]); + // An embedded profile wins over it. + let icc = cms::icc_out::generic_cmyk_icc(); + let img = to_display_rgba(Pixels::Cmyk(raster()), Some(icc), &device).unwrap(); + assert_eq!(img.get_pixel(0, 0).0, shown([255, 0, 0, 0], &DisplayColour::active())); + assert_eq!(img.get_pixel(1, 0).0[3], 128, "alpha kept"); + // A damaged one falls back to the working space. + let img = to_display_rgba(Pixels::Cmyk(raster()), Some(&icc[..200]), &device).unwrap(); + assert_eq!(img.get_pixel(0, 0).0, [0, 255, 255, 255]); + // Buffers that don't match the size are refused. + let bad = CmykRaster { width: 3, height: 1, cmyk: vec![0; 8], alpha: None }; + assert!(to_display_rgba(Pixels::Cmyk(bad), None, &device).is_none()); +} + +#[test] +fn rgb_with_an_embedded_profile_is_converted_to_srgb() { + let px = [64, 192, 64, 255, 200, 40, 40, 128]; + let png = png_with_icc(2, 1, &px, Some(adobe_rgb_icc())); + let img = decode_rgba(&png).unwrap(); + let green = img.get_pixel(0, 0).0; + let want = adobe_to_srgb([64, 192, 64]); + assert!(near(green, want, 2.0), "{green:?} vs {want:?}"); + // Adobe RGB's green is greener than sRGB's: shown as sRGB, the colour is more saturated. + assert!(green[0] < 50 && green[3] == 255, "{green:?}"); + assert_eq!(img.get_pixel(1, 0).0[3], 128, "alpha kept"); + + // An sRGB profile, or none, leaves the pixels exactly as stored. + let srgb = png_with_icc(2, 1, &px, Some(ColorProfile::new_srgb().encode().unwrap())); + assert_eq!(decode_rgba(&srgb).unwrap().as_raw(), &px); + assert_eq!(decode_rgba(&png_with_icc(2, 1, &px, None)).unwrap().as_raw(), &px); +} + +#[test] +fn gray_with_an_embedded_profile_is_converted() { + let img = image::RgbaImage::from_pixel(1, 1, Rgba([128, 128, 128, 255])); + let icc = ColorProfile::new_gray_with_gamma(1.0).encode().unwrap(); + let p = to_display_rgba(Pixels::Gray(img), Some(&icc), &DisplayColour::active()).unwrap().get_pixel(0, 0).0; + // Linear 50 % grey is sRGB 188. + assert!(p[0].abs_diff(188) <= 2 && p[0] == p[1] && p[1] == p[2], "{p:?}"); +} + +#[test] +fn unusable_profiles_leave_the_pixels_alone() { + let px = [64, 192, 64, 255]; + let adobe = adobe_rgb_icc(); + let cmyk = cms::icc_out::generic_cmyk_icc().to_vec(); + let mut huge = adobe.clone(); + huge.resize(MAX_ICC_BYTES + 1, 0); + let mut lying = adobe.clone(); + lying[128..132].copy_from_slice(&u32::MAX.to_be_bytes()); // tag count far beyond the data + for icc in [b"not a profile".to_vec(), adobe[..adobe.len() / 2].to_vec(), lying, cmyk, huge, Vec::new()] { + let img = image::RgbaImage::from_raw(1, 1, px.to_vec()).unwrap(); + let out = to_display_rgba(Pixels::Rgb(img), Some(&icc), &DisplayColour::active()).unwrap(); + assert_eq!(out.as_raw(), &px); + } + // Through the decoder too: a PNG whose profile is garbage still decodes. + let png = png_with_icc(1, 1, &px, Some(vec![0xAB; 300])); + assert_eq!(decode_rgba(&png).unwrap().as_raw(), &px); +} diff --git a/crates/images/src/jpeg.rs b/crates/images/src/jpeg.rs new file mode 100644 index 000000000..8b44d0c9a --- /dev/null +++ b/crates/images/src/jpeg.rs @@ -0,0 +1,80 @@ +//! CMYK and YCCK JPEGs, decoded to their ink values (the `image` crate converts them to RGB with +//! a profile-free formula). + +use zune_core::bytestream::ZCursor; +use zune_core::colorspace::ColorSpace; +use zune_core::options::DecoderOptions; +use zune_jpeg::JpegDecoder; + +use crate::CmykRaster; + +/// The largest decoded image, in bytes (the `image` crate's default allocation limit). +const MAX_BYTES: usize = 512 << 20; + +fn decoder(bytes: &[u8], out: ColorSpace) -> JpegDecoder> { + let options = DecoderOptions::default().set_strict_mode(false).set_max_width(65_535).set_max_height(65_535).jpeg_set_out_colorspace(out); + JpegDecoder::new_with_options(ZCursor::new(bytes), options) +} + +/// A four-channel JPEG's inks (0 = no ink) and its embedded ICC profile. `None` for other JPEGs +/// and for anything that doesn't decode. +pub(crate) fn decode_cmyk(bytes: &[u8]) -> Option<(CmykRaster, Option>)> { + if !bytes.starts_with(&[0xFF, 0xD8]) { + return None; + } + let mut d = decoder(bytes, ColorSpace::RGB); + d.decode_headers().ok()?; + let space = d.input_colorspace()?; + if !matches!(space, ColorSpace::CMYK | ColorSpace::YCCK) { + return None; + } + let info = d.info()?; + let (w, h) = (usize::from(info.width), usize::from(info.height)); + let pixels = w.checked_mul(h)?; + if pixels.checked_mul(4)? > MAX_BYTES { + return None; + } + // Decoding into the input colour space copies the channels as stored. + let mut d = decoder(bytes, space); + let mut px = d.decode().ok()?; + if px.len() != pixels.checked_mul(4)? { + return None; + } + let icc = d.icc_profile(); + if space == ColorSpace::YCCK { + // YCbCr encodes 255 − the first three stored values (JFIF conversion); K is as stored. + for p in px.as_chunks_mut::<4>().0 { + let (y, cb, cr) = (f32::from(p[0]), f32::from(p[1]) - 128.0, f32::from(p[2]) - 128.0); + let byte = |v: f32| 255 - v.round().clamp(0.0, 255.0) as u8; + p[0] = byte(y + 1.402 * cr); + p[1] = byte(y - 0.344_136 * cb - 0.714_136 * cr); + p[2] = byte(y + 1.772 * cb); + } + } + // Adobe's APP14 marker (always there for YCCK) means the values are stored inverted: + // 255 = no ink. + if space == ColorSpace::YCCK || adobe_marker(bytes) { + for v in &mut px { + *v = 255 - *v; + } + } + let raster = CmykRaster { width: u32::try_from(w).ok()?, height: u32::try_from(h).ok()?, cmyk: px, alpha: None }; + Some((raster, icc)) +} + +/// Whether an APP14 "Adobe" segment comes before the image data. +fn adobe_marker(bytes: &[u8]) -> bool { + let mut i = 2; + while let Some(&[0xFF, marker, hi, lo]) = bytes.get(i..i + 4).and_then(|s| <&[u8; 4]>::try_from(s).ok()) { + // Start of scan or end of image: no more header segments. + if marker == 0xDA || marker == 0xD9 { + return false; + } + let len = usize::from(u16::from_be_bytes([hi, lo])); + if marker == 0xEE && bytes.get(i + 4..i + 9) == Some(b"Adobe".as_slice()) { + return true; + } + i += 2 + len; + } + false +} diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs index 3bea7a3b3..61a454f59 100644 --- a/crates/images/src/lib.rs +++ b/crates/images/src/lib.rs @@ -8,7 +8,9 @@ use std::sync::{Arc, OnceLock}; pub use resvg::usvg; +pub mod colour; mod eps; +mod jpeg; mod psd; pub use eps::{bounding_box as eps_bounding_box, eps_proxy, is_eps}; @@ -222,12 +224,31 @@ pub fn pixel_size(bytes: &[u8]) -> Option<(u32, u32)> { image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok() } -/// Straight (non-premultiplied) RGBA8 of a raster. +/// Straight (non-premultiplied) RGBA8 of a raster in display sRGB: an embedded ICC profile is +/// applied, and CMYK goes through its own profile or the working CMYK space ([`colour`]). pub fn decode_rgba(bytes: &[u8]) -> Option { + use colour::{DisplayColour, Pixels, to_display_rgba}; if is_psd(bytes) { return psd::decode(bytes); } - Some(decode_to_rgba8(&image::load_from_memory(bytes).ok()?)) + if let Some((raster, icc)) = jpeg::decode_cmyk(bytes) { + return to_display_rgba(Pixels::Cmyk(raster), icc.as_deref(), &DisplayColour::active()); + } + if let Some(raster) = decode_cmyk_tiff(bytes) { + return to_display_rgba(Pixels::Cmyk(raster), embedded_icc(bytes).as_deref(), &DisplayColour::active()); + } + let img = image::load_from_memory(bytes).ok()?; + let rgba = decode_to_rgba8(&img); + let Some(icc) = embedded_icc(bytes) else { return Some(rgba) }; + let px = if img.color().has_color() { Pixels::Rgb(rgba) } else { Pixels::Gray(rgba) }; + to_display_rgba(px, Some(&icc), &DisplayColour::active()) +} + +/// The ICC profile embedded in a PNG, JPEG, TIFF or WebP. +fn embedded_icc(bytes: &[u8]) -> Option> { + use image::ImageDecoder; + let mut d = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?; + d.icc_profile().ok().flatten() } /// Fresh raster decodes are sRGB in the pinned image decoder. Expanding an L8 value to @@ -524,8 +545,10 @@ mod tests { 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]); + // The screen gets RGB through the working CMYK space. + let raster = CmykRaster { width: 2, height: 1, cmyk: px.to_vec(), alpha: None }; + let want = colour::to_display_rgba(colour::Pixels::Cmyk(raster), None, &colour::DisplayColour::active()).unwrap(); + assert_eq!(decode_rgba(&b).unwrap(), want); 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]); diff --git a/crates/images/src/psd.rs b/crates/images/src/psd.rs index 182005274..a9ff2227c 100644 --- a/crates/images/src/psd.rs +++ b/crates/images/src/psd.rs @@ -3,9 +3,11 @@ //! spot channels don't change the placed image; when the layer count is negative, the first extra //! channel is the composite's merged transparency. -use designcraft_color::Color; use designcraft_color::cms::{Lab, lab::lab_to_srgb}; +use crate::CmykRaster; +use crate::colour::{DisplayColour, Pixels, to_display_rgba}; + /// The largest decoded image, in RGBA bytes (the `image` crate's default allocation limit). const MAX_RGBA_BYTES: usize = 512 << 20; @@ -112,7 +114,7 @@ pub(crate) fn decode(bytes: &[u8]) -> Option { } let colour_mode_data = r.block(false)?; - r.block(false)?; // image resources + let icc = icc_profile(r.block(false)?); let layers = r.block(psb)?; let transparency = channels > colours && layer_count(layers, psb).is_some_and(|n| n < 0); let row_bytes = wu.checked_mul(usize::from(depth))?.div_ceil(8); @@ -165,19 +167,45 @@ pub(crate) fn decode(bytes: &[u8]) -> Option { [pr[i], pg[i], pb[i], a] } Mode::Rgb => [byte(v0), byte(v1), byte(v2), a], - Mode::Cmyk => { - // Stored inverted: 255 is no ink. - let ink = |v: f32| 1.0 - v / 255.0; - let [r, g, b] = Color::cmyk(ink(v0), ink(v1), ink(v2), ink(v3)).to_rgb_uncalibrated(); - [q(r), q(g), q(b), a] - } + // Stored inverted (255 is no ink): ink values, converted below. + Mode::Cmyk => [v0, v1, v2, v3].map(|v| 255 - byte(v)), Mode::Lab => { let [r, g, b] = lab_to_srgb(Lab::new(v0 * 100.0 / 255.0, v1 - 128.0, v2 - 128.0)); [q(r), q(g), q(b), a] } }; } - image::RgbaImage::from_raw(w, h, rgba) + let icc = icc.filter(|_| matches!(mode, Mode::Gray | Mode::Rgb | Mode::Cmyk)); + if mode == Mode::Cmyk { + let alpha = transparency.then_some(alpha); + let raster = CmykRaster { width: w, height: h, cmyk: rgba, alpha }; + return to_display_rgba(Pixels::Cmyk(raster), icc, &DisplayColour::active()); + } + let img = image::RgbaImage::from_raw(w, h, rgba)?; + match icc { + Some(icc) if mode == Mode::Gray => to_display_rgba(Pixels::Gray(img), Some(icc), &DisplayColour::active()), + Some(icc) => to_display_rgba(Pixels::Rgb(img), Some(icc), &DisplayColour::active()), + None => Some(img), + } +} + +/// The ICC profile in the image resources section (resource 1039), if any. +fn icc_profile(resources: &[u8]) -> Option<&[u8]> { + let mut r = Reader { b: resources }; + while r.take(4)? == b"8BIM" { + let id = r.u16()?; + // Pascal name, padded to an even length including its length byte. + let name_len = usize::from(*r.b.first()?); + r.take((name_len + 2) & !1)?; + let data = r.block(false)?; + if data.len() % 2 == 1 { + r.take(1)?; + } + if id == 1039 { + return Some(data); + } + } + None } /// The layer count from the layer and mask section. 16- and 32-bit documents keep their layer @@ -321,6 +349,8 @@ fn fill(dst: &mut [u8], slot: usize, step: usize, src: &[u8], depth: u16) { #[cfg(test)] mod tests { + use crate::CmykRaster; + use crate::colour::{DisplayColour, Pixels, to_display_rgba}; use crate::decode_rgba; const W: u32 = 12; @@ -373,11 +403,13 @@ mod tests { palette: Vec, layers: Layers, rle: bool, + /// An ICC profile resource (1039) when not empty. + icc: Vec, } impl Psd { fn new(mode: u16, channels: Vec>) -> Self { - Psd { version: 1, mode, depth: 8, w: W, h: H, channels, palette: Vec::new(), layers: Layers::None, rle: false } + Psd { version: 1, mode, depth: 8, w: W, h: H, channels, palette: Vec::new(), layers: Layers::None, rle: false, icc: Vec::new() } } fn rle(self) -> Self { Psd { rle: true, ..self } @@ -489,6 +521,16 @@ mod tests { for v in [72u32 << 16, 0x0001_0001, 72 << 16, 0x0001_0001] { res.extend(v.to_be_bytes()); } + if !self.icc.is_empty() { + res.extend(b"8BIM"); + res.extend(1039u16.to_be_bytes()); + res.extend([0, 0]); + res.extend((self.icc.len() as u32).to_be_bytes()); + res.extend(&self.icc); + if self.icc.len() % 2 == 1 { + res.push(0); + } + } b.extend((res.len() as u32).to_be_bytes()); b.extend(res); let lm = self.layer_and_mask(); @@ -606,23 +648,41 @@ mod tests { let m = plane(255, None); let y = plane(255, None); let k = plane(255, Some((6, 0, 12, 8, 127))); - let rgb8 = |c: f32, m: f32, y: f32, k: f32| { - let [r, g, b] = designcraft_color::Color::cmyk(c, m, y, k).to_rgb_uncalibrated(); - [r, g, b].map(|v| (v * 255.0).round() as u8) + // Through the working CMYK space, like CMYK JPEGs and TIFFs. + let expect = |cmyk: [u8; 4]| { + let raster = CmykRaster { width: 1, height: 1, cmyk: cmyk.to_vec(), alpha: None }; + to_display_rgba(Pixels::Cmyk(raster), None, &DisplayColour::active()).unwrap().get_pixel(0, 0).0 }; for rle in [false, true] { // An extra alpha channel (5 channels) doesn't become the alpha. let mut psd = Psd::new(4, vec![c.clone(), m.clone(), y.clone(), k.clone(), sel()]); psd.rle = rle; let px = pixels(&psd.write()); - let [r, g, b] = rgb8(1.0, 0.0, 0.0, 0.0); - assert_eq!(px[at(0, 0)], [r, g, b, 255]); - let [r, g, b] = rgb8(0.0, 0.0, 0.0, 128.0 / 255.0); - assert_eq!(px[at(11, 7)], [r, g, b, 255]); + let cyan = px[at(0, 0)]; + assert_eq!(cyan, expect([255, 0, 0, 0])); + assert!(cyan[1] < 200 && cyan[2] < 250, "press cyan, not (0, 255, 255): {cyan:?}"); + assert_eq!(px[at(11, 7)], expect([0, 0, 0, 128])); assert!(px.iter().all(|p| p[3] == 255)); } } + #[test] + fn embedded_profile_converts_rgb_to_srgb() { + let r = plane(64, None); + let g = plane(192, None); + let b = plane(64, None); + let plain = pixels(&Psd::new(3, vec![r.clone(), g.clone(), b.clone()]).write()); + assert_eq!(plain[0], [64, 192, 64, 255]); + let mut psd = Psd::new(3, vec![r, g, b]); + psd.icc = moxcms::ColorProfile::new_adobe_rgb().encode().unwrap(); + let px = pixels(&psd.write()); + // Adobe RGB green is greener than sRGB's: less red once shown as sRGB. + assert!(px[0][0] < 50 && px[0][1] > 180 && px[0][3] == 255, "{:?}", px[0]); + // A damaged profile is ignored. + psd.icc.truncate(100); + assert_eq!(pixels(&psd.write())[0], [64, 192, 64, 255]); + } + /// 8-bit plane → 16-bit big-endian samples (v × 257). fn wide(p: &[u8]) -> Vec { p.iter().flat_map(|&v| (v as u16 * 257).to_be_bytes()).collect() diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index e86d2a762..1f75205fc 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -1737,21 +1737,24 @@ mod image_content_cache_tests { for alpha in [None, Some(2), Some(1)] { let mut d = Document::new(&Default::default()); let bytes = crate::image_fixtures::cmyk_tiff(alpha, false); + // RGB-only exports get the RGB the screen shows. + let screen_rgb: Vec = + designcraft_images::decode_rgba(&bytes).map_or_else(Vec::new, |img| img.pixels().flat_map(|p| [p[0], p[1], p[2]]).collect()); let a = asset(&mut d, bytes.clone(), "a.tif"); let b = asset(&mut d, bytes, "b.tif"); for standard in [Standard::None, Standard::PdfA2b, Standard::PdfX4, Standard::PdfA2b, Standard::None] { let opts = PdfOptions { standard, ..Default::default() }; let mut ex = exporter(&d, &cache, &opts); ex.rgb_only = standard == Standard::PdfA2b; - // The pinned image decoder does not support CMYKA as RGB. Associated alpha - // is also rejected by the ink-preserving path: retain both failure names. - let succeeds = alpha.is_none() || (alpha == Some(2) && !ex.rgb_only); + // Unassociated alpha (2) decodes as inks, and as RGB for RGB-only exports. + // Associated alpha is rejected by both paths: retain both failure names. + let succeeds = alpha.is_none() || alpha == Some(2); let first = ex.load_image(a); assert_eq!(first.is_some(), succeeds, "{alpha:?} {standard:?}"); if alpha.is_none() { let (space, samples) = image_color_samples(first.unwrap()); assert_eq!(space, if ex.rgb_only { "DeviceRGB" } else { "DeviceCMYK" }); - assert_eq!(samples, if ex.rgb_only { vec![0, 0, 0, 255, 0, 0] } else { crate::image_fixtures::INKS.to_vec() }); + assert_eq!(samples, if ex.rgb_only { screen_rgb.clone() } else { crate::image_fixtures::INKS.to_vec() }); } assert_eq!(ex.load_image(b).is_some(), succeeds, "{alpha:?} {standard:?}"); assert_eq!(ex.load_image(a).is_some(), succeeds); diff --git a/crates/pdf/tests/support/image_content_cache_contract.rs b/crates/pdf/tests/support/image_content_cache_contract.rs index 4064404f8..daca88e55 100644 --- a/crates/pdf/tests/support/image_content_cache_contract.rs +++ b/crates/pdf/tests/support/image_content_cache_contract.rs @@ -245,6 +245,8 @@ fn successful_fallback_warns_for_both_names_and_preserves_rgb_samples() { #[test] fn pdfa_rgb_conversion_does_not_poison_later_cmyk_exports() { let bytes = fixtures::cmyk_tiff(None, false); + // PDF/A gets the RGB the screen shows: converted through the working CMYK space. + let rgb: Vec = designcraft_images::decode_rgba(&bytes).unwrap().pixels().flat_map(|p| [p[0], p[1], p[2]]).collect(); let mut d = Document::new(&Default::default()); place_named(&mut d, bytes.clone(), 36.0, "a.tif", "image/tiff", 0); place_named(&mut d, bytes, 66.0, "b.tif", "image/tiff", 0); @@ -256,7 +258,7 @@ fn pdfa_rgb_conversion_does_not_poison_later_cmyk_exports() { if standard == Standard::PdfA2b { assert_eq!(report.warnings, ["PDF/A: CMYK colours were converted to RGB (no CMYK output intent profile is available yet)"]); assert_eq!(images[0].space, "ICC"); - assert_eq!(images[0].samples, [0, 0, 0, 255, 0, 0]); + assert_eq!(images[0].samples, rgb); } else { assert!(report.warnings.is_empty()); assert_eq!(images[0].space, "DeviceCMYK"); diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index 117a0fa11..b68b09497 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -29,6 +29,8 @@ struct Cache { clock: u64, /// Assets that failed to decode (don't retry every frame). bad: HashMap<(usize, u32), Arc>>, + /// The colour settings the pixels were converted with ([`designcraft_color::cms::generation`]). + colour: u64, } static CACHE: Mutex> = Mutex::new(None); @@ -39,7 +41,13 @@ fn size_of(pm: &Pixmap) -> usize { fn with(f: impl FnOnce(&mut Cache) -> R) -> R { let mut g = CACHE.lock().unwrap_or_else(|e| e.into_inner()); - f(g.get_or_insert_with(Cache::default)) + let c = g.get_or_insert_with(Cache::default); + // Decoding converts CMYK through the working space: new colour settings, new pixels. + let colour = designcraft_color::cms::generation(); + if c.colour != colour { + *c = Cache { clock: c.clock, colour, ..Cache::default() }; + } + f(c) } fn lookup(key: (usize, u32, u8)) -> Option> { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c8e5f72e4..97f541a1a 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -54,9 +54,9 @@ score below). | Format | Ext | InDesign | DesignCraft | Notes | Weight | |---|---|---|---|---|---| | PDF / AI (PDF-compatible) | `.pdf`, `.ai` | ✓ page boxes, layers, pages | ✓ rendered with hayro, embedded as vector in PDF export, crop to page boxes | layer visibility depth; editable vectors not offered (#227) | 3% | -| JPEG, PNG, GIF, BMP, WebP | | ✓ | ✓ | CMYK JPEG/TIFF keep inks in PDF (#35) | 2% | -| TIFF | `.tif` | ✓ incl. CMYK, alpha, paths | ✓ incl. CMYK | clipping paths from 8BIM resources unverified | 1% | -| PSD | `.psd` | ✓ layers, comps, alpha, paths | ~ composite + Object Layer Options | layer comps partial | 1% | +| JPEG, PNG, GIF, BMP, WebP | | ✓ | ✓ embedded ICC profiles applied on screen; CMYK JPEGs (incl. YCCK) through their own profile or the working CMYK space | CMYK JPEG/TIFF keep inks in PDF (#35) | 2% | +| TIFF | `.tif` | ✓ incl. CMYK, alpha, paths | ✓ incl. CMYK, embedded ICC profiles on screen | clipping paths from 8BIM resources unverified | 1% | +| PSD | `.psd` | ✓ layers, comps, alpha, paths | ~ composite + Object Layer Options; embedded ICC profile and managed CMYK on screen | layer comps partial | 1% | | EPS | `.eps` | ✓ (PostScript) | ~ preview only (no interpreter) | | <1% | | SVG | `.svg` | ✓ | ✓ (usvg) | | <1% | | HEIC | `.heic` | ✓ | ✗ | | <1% | @@ -74,7 +74,7 @@ score below). |---|---|---|---| | Adobe Swatch Exchange | `.ase` | read/write | ✓ load and save | | Colour books | `.acb` | read (17 libraries ship) | ✗ (licensed libraries; Pantone etc. out of scope, own libraries possible) | -| ICC profiles | `.icc`, `.icm` | read | ✓ | +| ICC profiles | `.icc`, `.icm` | read | ✓ (also profiles embedded in placed images) | | Colour settings | `.csf` | read/write | ✗ | | PDF presets | `.joboptions`, `.pdfs` | read/write | ✗ | | Print / flattener / document presets | `.prst`, `.flst`, `.dcst` | read/write | ✗ | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ad041416d..7241c709d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -378,7 +378,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - PDF/X-4 export (`file.exportPdf {standard: "x4"}`): an output intent with our own Generic CMYK ICC profile (lut16 tables generated from the parametric press model), GTS_PDFXVersion in Info and XMP, `pdf:Trapped` in XMP as in Info, RGB images without a profile tagged sRGB, no image interpolation (none in PDF/A either), and built-in PDF/X-4 checks reported as warnings. qpdf finds no errors in the result. - EPS place: placed at its (high-resolution) bounding box and shown, printed and exported through its TIFF preview, or a placeholder when it has none. PostScript itself isn't interpreted. - Preferences: Dictionary (user dictionary; `spelling.setWords/words`), Spelling (Dynamic Spelling: red squiggles on the canvas, also Edit ▸ Spelling), Autocorrect (word list, applied as you type), Notes, Track Changes, Story Editor Display and File Handling (recovery interval) sections. -- Colour management exposed: Edit ▸ Color Settings (`color.settings`: RGB/CMYK working spaces, intent, black-point compensation; `color.loadProfile` for ICC files; `color.convert` through the working spaces, with gamut checks) and View ▸ Proof Setup / Proof Colors (press, sRGB, legacy Mac RGB, colour-blindness simulations, simulate paper) on the canvas. +- Colour management exposed: Edit ▸ Color Settings (`color.settings`: RGB/CMYK working spaces, intent, black-point compensation; `color.loadProfile` for ICC files; `color.convert` through the working spaces, with gamut checks) and View ▸ Proof Setup / Proof Colors (press, sRGB, legacy Mac RGB, colour-blindness simulations, simulate paper) on the canvas. Placed images are shown through their embedded ICC profiles, and CMYK images through their own profile or the working CMYK space, with the settings' intent and black-point compensation. - Keyboard: Tab / Shift+Tab hide and show all panels / all but Tools (`window.hidePanels`, `window.hidePanelsExceptTools`); Cmd+F6 / Cmd+Shift+F6 cycle documents; panel fields are reached with Tab and Escape returns to the layout. - Smart guides and snapping (View ▸ Snap to Guides / Snap to Document Grid / Smart Guides, `view.snapPreferences` and Preferences ▸ Guides & Pasteboard): moving, drawing, resizing, rotating and pen/anchor drags snap in order to the document grid, ruler guides (a page's own and its parent page's), margins and columns, other objects' edges and centres (parent-page items too, by visible stroked bounds), equal spacing (centred between two neighbours too), matching dimensions, the baseline grid and matching rotation, with guides, gap marks and measurements drawn on the canvas. Shift and Cmd constrain the gesture. - View ▸ Rotate Spread (`view.rotateSpread`, `layout.rotateSpreadView`): the spread in view turns in quarter turns on screen (stored with the document and in IDML's spread transform; output is unaffected). The canvas maps each spread through its own transform, so tools, snapping, resize handles, guides, overlays and rendering all follow the turned spread.