mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-24 10:16:59 -06:00
Merge craft-cms-adoption: colour management on craft-cms (next's image path)
This commit is contained in:
Generated
+10
-2
@@ -816,6 +816,14 @@ dependencies = [
|
||||
"rustix 1.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-cms"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=e9546e191dd62f2bc1d61eca45dbdc2f1b3c1d8e#e9546e191dd62f2bc1d61eca45dbdc2f1b3c1d8e"
|
||||
dependencies = [
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
@@ -947,8 +955,8 @@ dependencies = [
|
||||
name = "designcraft-color"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"craft-cms",
|
||||
"kurbo",
|
||||
"moxcms",
|
||||
"proptest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1087,11 +1095,11 @@ dependencies = [
|
||||
name = "designcraft-images"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"craft-cms",
|
||||
"designcraft-color",
|
||||
"designcraft-fonts",
|
||||
"image",
|
||||
"log",
|
||||
"moxcms",
|
||||
"resvg",
|
||||
"tiff",
|
||||
"zune-core",
|
||||
|
||||
@@ -74,6 +74,9 @@ craft-atomic = { version = "0.1.0", git = "https://github.com/storytold/craft-li
|
||||
# File › Print through the system print spooler (craft-libs).
|
||||
craft-print = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" }
|
||||
|
||||
# ICC colour management: working spaces, transforms, black point compensation (craft-libs).
|
||||
craft-cms = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "e9546e191dd62f2bc1d61eca45dbdc2f1b3c1d8e" }
|
||||
|
||||
# ui
|
||||
# Shared frontend components; keep this dependency above the engine boundary.
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
|
||||
@@ -12,7 +12,7 @@ workspace = true
|
||||
kurbo = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
moxcms = "0.8"
|
||||
craft-cms = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
|
||||
+106
-77
@@ -1,36 +1,38 @@
|
||||
//! ICC profiles through `moxcms` (pure Rust): built-in RGB spaces and user-supplied `.icc` files.
|
||||
//! ICC profiles through `craft-cms` (pure Rust): built-in RGB and CMYK spaces and user-supplied
|
||||
//! `.icc` files.
|
||||
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use moxcms::{ColorProfile, DataColorSpace, Layout, ProfileText, RenderingIntent, TransformF32Executor, TransformOptions};
|
||||
use craft_cms::{Builtin, ColorSpace, Profile, Transform, TransformOptions};
|
||||
|
||||
use super::lab::Lab;
|
||||
use super::{CmsError, Intent, ProfileKind};
|
||||
|
||||
fn moxcms_intent(i: Intent) -> RenderingIntent {
|
||||
match i {
|
||||
Intent::Perceptual => RenderingIntent::Perceptual,
|
||||
Intent::RelativeColorimetric => RenderingIntent::RelativeColorimetric,
|
||||
Intent::Saturation => RenderingIntent::Saturation,
|
||||
Intent::AbsoluteColorimetric => RenderingIntent::AbsoluteColorimetric,
|
||||
}
|
||||
type Xf = Option<Arc<Transform>>;
|
||||
|
||||
/// One transform per intent (in [`Intent::ALL`] order), without and with black point
|
||||
/// compensation.
|
||||
type Slots = [[OnceLock<Xf>; 2]; 4];
|
||||
|
||||
fn slot(slots: &Slots, intent: Intent, bpc: bool) -> &OnceLock<Xf> {
|
||||
let by_intent = match intent {
|
||||
Intent::Perceptual => &slots[0],
|
||||
Intent::RelativeColorimetric => &slots[1],
|
||||
Intent::Saturation => &slots[2],
|
||||
Intent::AbsoluteColorimetric => &slots[3],
|
||||
};
|
||||
if bpc { &by_intent[1] } else { &by_intent[0] }
|
||||
}
|
||||
|
||||
const INTENTS: [Intent; 4] = [Intent::Perceptual, Intent::RelativeColorimetric, Intent::Saturation, Intent::AbsoluteColorimetric];
|
||||
|
||||
fn intent_index(i: Intent) -> usize {
|
||||
INTENTS.iter().position(|x| *x == i).unwrap_or(1)
|
||||
}
|
||||
|
||||
type Xf = Option<Arc<TransformF32Executor>>;
|
||||
|
||||
/// A parsed ICC profile plus lazily-built transforms to/from sRGB.
|
||||
/// A parsed ICC profile plus lazily built transforms to and from sRGB and Lab.
|
||||
pub struct IccProfile {
|
||||
pub name: String,
|
||||
pub kind: ProfileKind,
|
||||
profile: ColorProfile,
|
||||
srgb: ColorProfile,
|
||||
to_srgb: [OnceLock<Xf>; 4],
|
||||
from_srgb: [OnceLock<Xf>; 4],
|
||||
profile: Arc<Profile>,
|
||||
to_srgb: Slots,
|
||||
from_srgb: Slots,
|
||||
to_lab: OnceLock<Xf>,
|
||||
from_lab: Slots,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for IccProfile {
|
||||
@@ -39,46 +41,60 @@ impl std::fmt::Debug for IccProfile {
|
||||
}
|
||||
}
|
||||
|
||||
fn text(t: &Option<ProfileText>) -> Option<String> {
|
||||
match t.as_ref()? {
|
||||
ProfileText::PlainString(s) => Some(s.clone()),
|
||||
ProfileText::Localizable(v) => v.iter().find(|l| l.language == "en").or(v.first()).map(|l| l.value.clone()),
|
||||
ProfileText::Description(d) => Some(if d.ascii_string.is_empty() { d.unicode_string.clone() } else { d.ascii_string.clone() }),
|
||||
fn build(src: &Profile, dst: &Profile, intent: Intent, bpc: bool) -> Xf {
|
||||
Transform::with_options(src, dst, TransformOptions { intent: intent.into(), bpc, precise_float: true }).ok().map(Arc::new)
|
||||
}
|
||||
|
||||
/// Evaluate one colour exactly, each output clamped to 0..1; `None` when the transform's channel
|
||||
/// counts don't fit or the result isn't finite.
|
||||
fn eval(x: &Transform, src: &[f32], outputs: usize) -> Option<Vec<f32>> {
|
||||
if x.inputs() != src.len() || x.outputs() != outputs {
|
||||
return None;
|
||||
}
|
||||
let src: Vec<f32> = src.iter().map(|v| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }).collect();
|
||||
let mut out = vec![0.0f32; outputs];
|
||||
x.eval(&src, &mut out).ok()?;
|
||||
out.iter().all(|o| o.is_finite()).then(|| out.into_iter().map(|v| v.clamp(0.0, 1.0)).collect())
|
||||
}
|
||||
|
||||
fn eval3(x: &Transform, src: &[f32]) -> Option<[f32; 3]> {
|
||||
match eval(x, src, 3)?.as_slice() {
|
||||
&[a, b, c] => Some([a, b, c]),
|
||||
_ => None,
|
||||
}
|
||||
.map(|s| s.trim_matches(char::from(0)).trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
impl IccProfile {
|
||||
pub fn from_profile(name: Option<String>, profile: ColorProfile) -> Result<Self, CmsError> {
|
||||
pub fn from_profile(name: Option<String>, profile: Arc<Profile>) -> Result<Self, CmsError> {
|
||||
let kind = match profile.color_space {
|
||||
DataColorSpace::Rgb => ProfileKind::Rgb,
|
||||
DataColorSpace::Cmyk => ProfileKind::Cmyk,
|
||||
DataColorSpace::Gray => ProfileKind::Gray,
|
||||
ColorSpace::Rgb => ProfileKind::Rgb,
|
||||
ColorSpace::Cmyk => ProfileKind::Cmyk,
|
||||
ColorSpace::Gray => ProfileKind::Gray,
|
||||
other => return Err(CmsError::Unsupported(format!("profile colour space {other:?} is not RGB, CMYK or Gray"))),
|
||||
};
|
||||
let name = name.or_else(|| text(&profile.description)).unwrap_or_else(|| format!("Untitled {kind:?} profile"));
|
||||
Ok(Self { name, kind, profile, srgb: ColorProfile::new_srgb(), to_srgb: Default::default(), from_srgb: Default::default() })
|
||||
let desc = profile.description.trim_matches(char::from(0)).trim();
|
||||
let name = name.or_else(|| (!desc.is_empty()).then(|| desc.to_string())).unwrap_or_else(|| format!("Untitled {kind:?} profile"));
|
||||
Ok(Self {
|
||||
name,
|
||||
kind,
|
||||
profile,
|
||||
to_srgb: Default::default(),
|
||||
from_srgb: Default::default(),
|
||||
to_lab: OnceLock::new(),
|
||||
from_lab: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn from_bytes(name: Option<String>, bytes: &[u8]) -> Result<Self, CmsError> {
|
||||
let p = ColorProfile::new_from_slice(bytes).map_err(|e| CmsError::BadProfile(format!("{e:?}")))?;
|
||||
Self::from_profile(name, p)
|
||||
let p = Profile::parse(bytes).map_err(|e| CmsError::BadProfile(e.to_string()))?;
|
||||
Self::from_profile(name, Arc::new(p))
|
||||
}
|
||||
|
||||
/// The parsed profile.
|
||||
pub fn profile(&self) -> &ColorProfile {
|
||||
pub fn profile(&self) -> &Arc<Profile> {
|
||||
&self.profile
|
||||
}
|
||||
|
||||
fn layout(&self) -> Layout {
|
||||
match self.kind {
|
||||
ProfileKind::Cmyk => Layout::Rgba, // 4 channels
|
||||
ProfileKind::Gray => Layout::Gray,
|
||||
ProfileKind::Rgb => Layout::Rgb,
|
||||
}
|
||||
}
|
||||
|
||||
fn channels(&self) -> usize {
|
||||
match self.kind {
|
||||
ProfileKind::Cmyk => 4,
|
||||
@@ -87,54 +103,67 @@ impl IccProfile {
|
||||
}
|
||||
}
|
||||
|
||||
fn opts(intent: Intent) -> TransformOptions {
|
||||
TransformOptions { rendering_intent: moxcms_intent(intent), ..Default::default() }
|
||||
fn to_xf(&self, intent: Intent, bpc: bool) -> Xf {
|
||||
slot(&self.to_srgb, intent, bpc).get_or_init(|| build(&self.profile, Builtin::Srgb.profile(), intent, bpc)).clone()
|
||||
}
|
||||
|
||||
fn to_xf(&self, intent: Intent) -> Xf {
|
||||
self.to_srgb[intent_index(intent)]
|
||||
.get_or_init(|| self.profile.create_transform_f32(self.layout(), &self.srgb, Layout::Rgb, Self::opts(intent)).ok())
|
||||
.clone()
|
||||
fn inverse_xf(&self, intent: Intent, bpc: bool) -> Xf {
|
||||
slot(&self.from_srgb, intent, bpc).get_or_init(|| build(Builtin::Srgb.profile(), &self.profile, intent, bpc)).clone()
|
||||
}
|
||||
|
||||
fn inverse_xf(&self, intent: Intent) -> Xf {
|
||||
self.from_srgb[intent_index(intent)]
|
||||
.get_or_init(|| self.srgb.create_transform_f32(Layout::Rgb, &self.profile, self.layout(), Self::opts(intent)).ok())
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Device values (RGB / CMYK / Gray, 0..1) → sRGB.
|
||||
pub fn to_srgb(&self, v: &[f32], intent: Intent) -> Option<[f32; 3]> {
|
||||
let xf = self.to_xf(intent)?;
|
||||
/// Device values (RGB / CMYK / Gray, 0..1; missing channels are 0) → sRGB.
|
||||
pub fn to_srgb(&self, v: &[f32], intent: Intent, bpc: bool) -> Option<[f32; 3]> {
|
||||
let mut src = v.to_vec();
|
||||
src.resize(self.channels(), 0.0);
|
||||
let mut dst = [0.0f32; 3];
|
||||
xf.transform(&src, &mut dst).ok()?;
|
||||
Some(dst.map(|x| x.clamp(0.0, 1.0)))
|
||||
eval3(&*self.to_xf(intent, bpc)?, &src)
|
||||
}
|
||||
|
||||
/// sRGB → device values.
|
||||
pub fn from_srgb(&self, rgb: [f32; 3], intent: Intent) -> Option<Vec<f32>> {
|
||||
let xf = self.inverse_xf(intent)?;
|
||||
let mut dst = vec![0.0f32; self.channels()];
|
||||
xf.transform(&rgb, &mut dst).ok()?;
|
||||
Some(dst.into_iter().map(|x| x.clamp(0.0, 1.0)).collect())
|
||||
pub fn from_srgb(&self, rgb: [f32; 3], intent: Intent, bpc: bool) -> Option<Vec<f32>> {
|
||||
let x = self.inverse_xf(intent, bpc)?;
|
||||
eval(&x, &rgb, self.channels())
|
||||
}
|
||||
|
||||
/// Media-relative Lab (D50) of device values: relative colorimetric, no black point
|
||||
/// compensation.
|
||||
pub fn to_lab(&self, v: &[f32]) -> Option<Lab> {
|
||||
let x = self.to_lab.get_or_init(|| build(&self.profile, Builtin::LabD50.profile(), Intent::RelativeColorimetric, false)).clone()?;
|
||||
let mut src = v.to_vec();
|
||||
src.resize(self.channels(), 0.0);
|
||||
let [l, a, b] = eval3(&x, &src)?;
|
||||
// Lab D50 device values use the ICC v4 encoding: L/100, (a + 128)/255.
|
||||
Some(Lab::new(l * 100.0, a * 255.0 - 128.0, b * 255.0 - 128.0))
|
||||
}
|
||||
|
||||
/// Lab (D50) → device values with `intent`.
|
||||
pub fn from_lab(&self, lab: Lab, intent: Intent, bpc: bool) -> Option<Vec<f32>> {
|
||||
let x = slot(&self.from_lab, intent, bpc).get_or_init(|| build(Builtin::LabD50.profile(), &self.profile, intent, bpc)).clone()?;
|
||||
eval(&x, &[lab.l / 100.0, (lab.a + 128.0) / 255.0, (lab.b + 128.0) / 255.0], self.channels())
|
||||
}
|
||||
|
||||
/// Whether transforms can be built for this profile (checked at registration).
|
||||
pub fn usable(&self) -> bool {
|
||||
self.to_xf(Intent::RelativeColorimetric).is_some() && self.inverse_xf(Intent::RelativeColorimetric).is_some()
|
||||
self.to_xf(Intent::RelativeColorimetric, false).is_some() && self.inverse_xf(Intent::RelativeColorimetric, false).is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// Built-in RGB working spaces from `moxcms`'s standard primaries.
|
||||
fn builtin(name: &str, b: Builtin) -> Option<IccProfile> {
|
||||
IccProfile::from_profile(Some(name.to_string()), Arc::new(b.profile().clone())).ok()
|
||||
}
|
||||
|
||||
/// Built-in RGB working spaces from craft-cms's built-in profiles.
|
||||
pub fn builtin_rgb(name: &str) -> Option<IccProfile> {
|
||||
let p = match name {
|
||||
super::ADOBE_RGB => ColorProfile::new_adobe_rgb(),
|
||||
super::DISPLAY_P3 => ColorProfile::new_display_p3(),
|
||||
super::PROPHOTO_RGB => ColorProfile::new_pro_photo_rgb(),
|
||||
super::SRGB => ColorProfile::new_srgb(),
|
||||
let b = match name {
|
||||
super::ADOBE_RGB => Builtin::AdobeRgbCompat,
|
||||
super::DISPLAY_P3 => Builtin::DisplayP3,
|
||||
super::PROPHOTO_RGB => Builtin::ProPhotoCompat,
|
||||
super::SRGB => Builtin::Srgb,
|
||||
_ => return None,
|
||||
};
|
||||
IccProfile::from_profile(Some(name.to_string()), p).ok()
|
||||
builtin(name, b)
|
||||
}
|
||||
|
||||
/// The Coated FOGRA39 built-in, listed as [`super::COATED_FOGRA39`].
|
||||
pub fn coated_fogra39() -> Option<IccProfile> {
|
||||
builtin(super::COATED_FOGRA39, Builtin::CoatedFogra39)
|
||||
}
|
||||
|
||||
@@ -5,97 +5,74 @@
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use moxcms::{
|
||||
ColorProfile, DataColorSpace, LocalizableString, LutDataType, LutStore, LutType, LutWarehouse, Matrix3d, ProfileClass, ProfileText,
|
||||
RenderingIntent,
|
||||
};
|
||||
use craft_cms::profile::{Lut, LutKind};
|
||||
use craft_cms::{Builtin, Clut, Curve, Intent as CmsIntent, Profile, pipeline::Stage};
|
||||
|
||||
use super::Intent;
|
||||
use super::lab::Lab;
|
||||
use super::{CmsError, Intent};
|
||||
|
||||
const A2B_GRID: usize = 9;
|
||||
const B2A_GRID: usize = 17;
|
||||
|
||||
/// ICC v2 legacy 16-bit Lab encoding (lut16Type).
|
||||
fn enc_lab(l: Lab) -> [u16; 3] {
|
||||
let q = |v: f32| v.round().clamp(0.0, 65535.0) as u16;
|
||||
[q(l.l * 652.8), q((l.a + 128.0) * 256.0), q((l.b + 128.0) * 256.0)]
|
||||
/// ICC v2 legacy 16-bit Lab encoding (lut16Type), normalised to 0..1.
|
||||
const V2_L: f32 = 100.0 * 65535.0 / 65280.0;
|
||||
const V2_AB: f32 = 65535.0 / 256.0;
|
||||
|
||||
fn enc_lab(l: Lab) -> [f32; 3] {
|
||||
[l.l / V2_L, (l.a + 128.0) / V2_AB, (l.b + 128.0) / V2_AB].map(|v| v.clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
fn dec_lab(v: [u16; 3]) -> Lab {
|
||||
Lab::new(v[0] as f32 / 652.8, v[1] as f32 / 256.0 - 128.0, v[2] as f32 / 256.0 - 128.0)
|
||||
fn dec_lab(v: &[f32]) -> Lab {
|
||||
let at = |i: usize| v.get(i).copied().unwrap_or(0.0);
|
||||
Lab::new(at(0) * V2_L, at(1) * V2_AB - 128.0, at(2) * V2_AB - 128.0)
|
||||
}
|
||||
|
||||
fn lut(inputs: u8, outputs: u8, grid: usize, clut: Vec<u16>) -> LutWarehouse {
|
||||
let ident = |n: u8| LutStore::Store16((0..n).flat_map(|_| [0u16, 65535]).collect());
|
||||
LutWarehouse::Lut(LutDataType {
|
||||
num_input_channels: inputs,
|
||||
num_output_channels: outputs,
|
||||
num_clut_grid_points: grid as u8,
|
||||
matrix: Matrix3d::IDENTITY,
|
||||
num_input_table_entries: 2,
|
||||
num_output_table_entries: 2,
|
||||
input_table: ident(inputs),
|
||||
clut_table: LutStore::Store16(clut),
|
||||
output_table: ident(outputs),
|
||||
lut_type: LutType::Lut16,
|
||||
})
|
||||
}
|
||||
|
||||
fn text(s: &str) -> Option<ProfileText> {
|
||||
Some(ProfileText::Localizable(vec![LocalizableString::new("en".into(), "US".into(), s.into())]))
|
||||
fn lut16(clut: Clut) -> Lut {
|
||||
let (i, o) = (clut.inputs(), clut.outputs());
|
||||
let ident = |n: usize| Stage::Curves(vec![Curve::Table(vec![0.0, 1.0]); n]);
|
||||
Lut { kind: LutKind::Lut16, inputs: i, outputs: o, stages: vec![ident(i), Stage::Clut(clut), ident(o)] }
|
||||
}
|
||||
|
||||
/// The Generic CMYK model as an ICC output profile (built once).
|
||||
pub fn generic_cmyk_icc() -> &'static [u8] {
|
||||
static P: OnceLock<Vec<u8>> = OnceLock::new();
|
||||
P.get_or_init(|| {
|
||||
let g = super::generic();
|
||||
let step = |i: usize, n: usize| i as f32 / (n - 1) as f32;
|
||||
// CMYK → Lab (media-relative), first input channel varying slowest.
|
||||
let mut a2b = Vec::with_capacity(A2B_GRID.pow(4) * 3);
|
||||
for c in 0..A2B_GRID {
|
||||
for m in 0..A2B_GRID {
|
||||
for y in 0..A2B_GRID {
|
||||
for k in 0..A2B_GRID {
|
||||
let lab = g.to_lab_rel([step(c, A2B_GRID), step(m, A2B_GRID), step(y, A2B_GRID), step(k, A2B_GRID)]);
|
||||
a2b.extend(enc_lab(lab));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Lab → CMYK through the model's separation.
|
||||
let mut b2a = Vec::with_capacity(B2A_GRID.pow(3) * 4);
|
||||
let e = |i: usize| (i as f32 * 65535.0 / (B2A_GRID - 1) as f32).round() as u16;
|
||||
for l in 0..B2A_GRID {
|
||||
for a in 0..B2A_GRID {
|
||||
for b in 0..B2A_GRID {
|
||||
let lab = dec_lab([e(l), e(a), e(b)]);
|
||||
let cmyk = g.from_lab(lab, Intent::RelativeColorimetric, true);
|
||||
b2a.extend(cmyk.map(|v| (v.clamp(0.0, 1.0) * 65535.0).round() as u16));
|
||||
}
|
||||
}
|
||||
}
|
||||
let a2b = lut(4, 3, A2B_GRID, a2b);
|
||||
let b2a = lut(3, 4, B2A_GRID, b2a);
|
||||
let mut p = ColorProfile::default();
|
||||
p.pcs = DataColorSpace::Lab;
|
||||
p.color_space = DataColorSpace::Cmyk;
|
||||
p.profile_class = ProfileClass::OutputDevice;
|
||||
p.rendering_intent = RenderingIntent::RelativeColorimetric;
|
||||
let d50 = moxcms::Xyzd { x: 0.9642, y: 1.0, z: 0.8249 };
|
||||
p.white_point = d50;
|
||||
p.media_white_point = Some(d50);
|
||||
p.lut_a_to_b_perceptual = Some(a2b.clone());
|
||||
p.lut_a_to_b_colorimetric = Some(a2b.clone());
|
||||
p.lut_a_to_b_saturation = Some(a2b);
|
||||
p.lut_b_to_a_perceptual = Some(b2a.clone());
|
||||
p.lut_b_to_a_colorimetric = Some(b2a.clone());
|
||||
p.lut_b_to_a_saturation = Some(b2a);
|
||||
p.description = text(super::GENERIC_CMYK);
|
||||
p.copyright = text("No copyright; made by DesignCraft from its parametric press model. Use freely.");
|
||||
p.encode().unwrap_or_default()
|
||||
pub fn generic_cmyk_profile() -> Result<&'static Profile, CmsError> {
|
||||
static P: OnceLock<Result<Profile, CmsError>> = OnceLock::new();
|
||||
P.get_or_init(build_generic_cmyk).as_ref().map_err(Clone::clone)
|
||||
}
|
||||
|
||||
fn build_generic_cmyk() -> Result<Profile, CmsError> {
|
||||
let bad = |e: craft_cms::CmsError| CmsError::BadProfile(format!("{}: {e}", super::GENERIC_CMYK));
|
||||
let g = super::generic();
|
||||
// CMYK → Lab (media-relative).
|
||||
let a2b = Clut::sample(vec![A2B_GRID; 4], 3, |inp, out| {
|
||||
let at = |i: usize| inp.get(i).copied().unwrap_or(0.0);
|
||||
out.copy_from_slice(&enc_lab(g.to_lab_rel([at(0), at(1), at(2), at(3)])));
|
||||
})
|
||||
.map_err(bad)?;
|
||||
// Lab → CMYK through the model's separation.
|
||||
let b2a = Clut::sample(vec![B2A_GRID; 3], 4, |inp, out| {
|
||||
let cmyk = g.from_lab(dec_lab(inp), Intent::RelativeColorimetric, true);
|
||||
out.copy_from_slice(&cmyk.map(|v| v.clamp(0.0, 1.0)));
|
||||
})
|
||||
.map_err(bad)?;
|
||||
let (a2b, b2a) = (lut16(a2b), lut16(b2a));
|
||||
// craft-cms profiles can't be constructed field by field outside the crate, so the
|
||||
// tables replace those of a built-in CMYK output profile and the result is re-encoded.
|
||||
let mut p = Builtin::CoatedCmyk.profile().clone();
|
||||
p.rendering_intent = CmsIntent::RelativeColorimetric;
|
||||
p.description = super::GENERIC_CMYK.into();
|
||||
p.copyright = "No copyright; made by DesignCraft from its parametric press model. Use freely.".into();
|
||||
p.white_point = [0.9642, 1.0, 0.8249];
|
||||
p.black_point = None;
|
||||
p.chad = None;
|
||||
p.a2b = [Some(a2b.clone()), Some(a2b.clone()), Some(a2b)];
|
||||
p.b2a = [Some(b2a.clone()), Some(b2a.clone()), Some(b2a)];
|
||||
Ok(p.with_encoded_bytes())
|
||||
}
|
||||
|
||||
/// [`generic_cmyk_profile`]'s ICC bytes.
|
||||
pub fn generic_cmyk_icc() -> Result<&'static [u8], CmsError> {
|
||||
static B: OnceLock<Result<std::sync::Arc<Vec<u8>>, CmsError>> = OnceLock::new();
|
||||
B.get_or_init(|| generic_cmyk_profile().map(Profile::to_bytes)).as_ref().map(|b| b.as_slice()).map_err(Clone::clone)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -104,16 +81,17 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn generic_cmyk_profile_round_trips() {
|
||||
let bytes = generic_cmyk_icc();
|
||||
let bytes = generic_cmyk_icc().unwrap();
|
||||
assert!(bytes.len() > 1000);
|
||||
assert_eq!(&bytes[36..40], b"acsp");
|
||||
assert_eq!(&bytes[16..20], b"CMYK");
|
||||
assert_eq!(&bytes[12..16], b"prtr");
|
||||
// Read back: CMYK → sRGB through the profile is close to the model.
|
||||
let p = super::super::icc::IccProfile::from_bytes(None, bytes).expect("parses");
|
||||
assert_eq!(p.name, super::super::GENERIC_CMYK);
|
||||
let g = super::super::generic();
|
||||
for cmyk in [[0.0, 0.0, 0.0, 0.0], [1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 1.0, 0.0], [0.2, 0.3, 0.1, 0.5]] {
|
||||
let via = p.to_srgb(&cmyk, Intent::RelativeColorimetric).expect("converts");
|
||||
let via = p.to_srgb(&cmyk, Intent::RelativeColorimetric, false).expect("converts");
|
||||
let model = g.to_srgb(cmyk, false);
|
||||
for i in 0..3 {
|
||||
assert!((via[i] - model[i]).abs() < 0.04, "{cmyk:?}: {via:?} vs {model:?}");
|
||||
|
||||
+111
-28
@@ -1,11 +1,12 @@
|
||||
//! Colour management: working spaces, RGB ↔ CMYK ↔ Lab conversions with rendering intents,
|
||||
//! gamut checks and soft-proof transforms.
|
||||
//!
|
||||
//! * **RGB working spaces** are ICC matrix/shaper profiles via [`moxcms`] (sRGB, Adobe RGB (1998)
|
||||
//! compatible, Display P3, ProPhoto RGB) or user `.icc` files. Display output is sRGB.
|
||||
//! * **CMYK spaces:** [`GENERIC_CMYK`] is our own documented parametric press model
|
||||
//! ([`generic`]); [`DEVICE_CMYK`] is the old profile-free formula (kept for exact legacy numbers);
|
||||
//! any CMYK `.icc` the user loads goes through `moxcms` (LUT-based profiles).
|
||||
//! * **ICC profiles** go through `craft-cms`: the built-in RGB working spaces (sRGB, Adobe RGB
|
||||
//! (1998) compatible, Display P3, ProPhoto RGB), the default CMYK space [`COATED_FOGRA39`],
|
||||
//! and any `.icc` the user loads. Display output is sRGB; CMYK is shown relative colorimetric
|
||||
//! with the settings' black point compensation, as placed CMYK images are.
|
||||
//! * **Other CMYK spaces:** [`GENERIC_CMYK`] is our own documented parametric press model
|
||||
//! ([`generic`]); [`DEVICE_CMYK`] is the old profile-free formula (kept for exact legacy numbers).
|
||||
//! * The **active settings** (Edit → Color Settings) are process-wide; [`crate::Color::to_rgb`]
|
||||
//! uses them. Code that needs deterministic numbers builds its own [`Cms`] with [`Cms::new`].
|
||||
|
||||
@@ -14,6 +15,7 @@ pub mod icc;
|
||||
pub mod icc_out;
|
||||
pub mod lab;
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock, RwLock};
|
||||
|
||||
@@ -29,6 +31,9 @@ pub const SRGB: &str = "sRGB IEC61966-2.1";
|
||||
pub const ADOBE_RGB: &str = "Adobe RGB (1998) compatible";
|
||||
pub const DISPLAY_P3: &str = "Display P3";
|
||||
pub const PROPHOTO_RGB: &str = "ProPhoto RGB";
|
||||
/// craft-cms's coated offset profile fitted to the FOGRA39 characterisation data: the default
|
||||
/// CMYK working space.
|
||||
pub const COATED_FOGRA39: &str = craft_cms::builtin::COATED_FOGRA39_DESCRIPTION;
|
||||
/// Our parametric press model (see [`generic`]). Not an Adobe/ECI profile.
|
||||
pub const GENERIC_CMYK: &str = "DesignCraft Generic CMYK (SWOP-like)";
|
||||
/// Profile-free CMYK (`rgb = (1−c)(1−k)` …): uncalibrated, kept for legacy numbers.
|
||||
@@ -78,6 +83,17 @@ impl Intent {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Intent> for craft_cms::Intent {
|
||||
fn from(i: Intent) -> Self {
|
||||
match i {
|
||||
Intent::Perceptual => craft_cms::Intent::Perceptual,
|
||||
Intent::RelativeColorimetric => craft_cms::Intent::RelativeColorimetric,
|
||||
Intent::Saturation => craft_cms::Intent::Saturation,
|
||||
Intent::AbsoluteColorimetric => craft_cms::Intent::AbsoluteColorimetric,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ProfileKind {
|
||||
@@ -122,10 +138,33 @@ pub struct ColorSettings {
|
||||
|
||||
impl Default for ColorSettings {
|
||||
fn default() -> Self {
|
||||
Self { rgb: SRGB.into(), cmyk: GENERIC_CMYK.into(), intent: Intent::RelativeColorimetric, bpc: true }
|
||||
Self { rgb: SRGB.into(), cmyk: COATED_FOGRA39.into(), intent: Intent::RelativeColorimetric, bpc: true }
|
||||
}
|
||||
}
|
||||
|
||||
impl ColorSettings {
|
||||
/// These settings with each working space that isn't available (a profile that is no longer
|
||||
/// loaded, a name from another version) replaced by the default one: for saved settings.
|
||||
pub fn with_known_profiles(&self) -> Self {
|
||||
let d = Self::default();
|
||||
Self {
|
||||
rgb: if rgb_space(&self.rgb).is_ok() { self.rgb.clone() } else { d.rgb },
|
||||
cmyk: if cmyk_space(&self.cmyk).is_ok() { self.cmyk.clone() } else { d.cmyk },
|
||||
..self.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A PDF/X output intent: the destination profile and the condition it names.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OutputIntent {
|
||||
pub icc: Cow<'static, [u8]>,
|
||||
/// `OutputConditionIdentifier`: a registered characterisation, or `Custom`.
|
||||
pub identifier: &'static str,
|
||||
/// `OutputCondition` / `Info`: the profile's name.
|
||||
pub condition: String,
|
||||
}
|
||||
|
||||
/// Target colour model for [`Cms::convert`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Model {
|
||||
@@ -147,6 +186,12 @@ enum RgbSpace {
|
||||
Icc(Arc<IccProfile>),
|
||||
}
|
||||
|
||||
/// The built-in Coated FOGRA39 space; `None` only if craft-cms can't use its own profile.
|
||||
fn fogra39() -> Option<Arc<IccProfile>> {
|
||||
static P: OnceLock<Option<Arc<IccProfile>>> = OnceLock::new();
|
||||
P.get_or_init(|| icc::coated_fogra39().map(Arc::new)).clone()
|
||||
}
|
||||
|
||||
fn generic() -> Arc<GenericCmyk> {
|
||||
static G: OnceLock<Arc<GenericCmyk>> = OnceLock::new();
|
||||
G.get_or_init(|| Arc::new(GenericCmyk::new(generic::swop_like()))).clone()
|
||||
@@ -169,13 +214,21 @@ fn user_profile(name: &str) -> Option<Arc<IccProfile>> {
|
||||
USER.read().unwrap_or_else(|e| e.into_inner()).iter().find(|p| p.name == name).cloned()
|
||||
}
|
||||
|
||||
/// The default CMYK space: Coated FOGRA39, or Generic CMYK should that profile be unusable.
|
||||
fn default_cmyk() -> CmykSpace {
|
||||
fogra39().map_or_else(|| CmykSpace::Generic(generic()), CmykSpace::Icc)
|
||||
}
|
||||
|
||||
fn cmyk_space(name: &str) -> Result<CmykSpace, CmsError> {
|
||||
match name {
|
||||
GENERIC_CMYK => Ok(CmykSpace::Generic(generic())),
|
||||
DEVICE_CMYK => Ok(CmykSpace::Device),
|
||||
COATED_FOGRA39 => Ok(default_cmyk()),
|
||||
_ => match user_profile(name) {
|
||||
Some(p) if p.kind == ProfileKind::Cmyk => Ok(CmykSpace::Icc(p)),
|
||||
Some(_) => Err(CmsError::Unsupported(format!("{name} is not a CMYK profile"))),
|
||||
// Names of the profiles the built-in stands in for (Coated FOGRA39 (ISO 12647-2:2004), ISO Coated v2 (ECI) …).
|
||||
None if craft_cms::Builtin::stand_in_for(name) == Some(craft_cms::Builtin::CoatedFogra39) => Ok(default_cmyk()),
|
||||
None => Err(CmsError::UnknownProfile(name.into())),
|
||||
},
|
||||
}
|
||||
@@ -200,7 +253,7 @@ pub fn profiles() -> Vec<ProfileInfo> {
|
||||
let mut v: Vec<ProfileInfo> = [SRGB, ADOBE_RGB, DISPLAY_P3, PROPHOTO_RGB]
|
||||
.iter()
|
||||
.map(|n| ProfileInfo { name: (*n).into(), kind: ProfileKind::Rgb, builtin: true })
|
||||
.chain([GENERIC_CMYK, DEVICE_CMYK].iter().map(|n| ProfileInfo { name: (*n).into(), kind: ProfileKind::Cmyk, builtin: true }))
|
||||
.chain([COATED_FOGRA39, GENERIC_CMYK, DEVICE_CMYK].iter().map(|n| ProfileInfo { name: (*n).into(), kind: ProfileKind::Cmyk, builtin: true }))
|
||||
.collect();
|
||||
for p in USER.read().unwrap_or_else(|e| e.into_inner()).iter() {
|
||||
v.push(ProfileInfo { name: p.name.clone(), kind: p.kind, builtin: false });
|
||||
@@ -215,7 +268,7 @@ pub fn register_icc(bytes: &[u8], name: Option<String>) -> Result<ProfileInfo, C
|
||||
if p.kind == ProfileKind::Gray {
|
||||
return Err(CmsError::Unsupported("grayscale profiles can't be working spaces yet".into()));
|
||||
}
|
||||
if [SRGB, ADOBE_RGB, DISPLAY_P3, PROPHOTO_RGB, GENERIC_CMYK, DEVICE_CMYK].contains(&p.name.as_str()) {
|
||||
if [SRGB, ADOBE_RGB, DISPLAY_P3, PROPHOTO_RGB, COATED_FOGRA39, GENERIC_CMYK, DEVICE_CMYK].contains(&p.name.as_str()) {
|
||||
return Err(CmsError::Unsupported(format!("{} clashes with a built-in profile name", p.name)));
|
||||
}
|
||||
if !p.usable() {
|
||||
@@ -254,7 +307,7 @@ impl std::fmt::Debug for Cms {
|
||||
|
||||
impl Default for Cms {
|
||||
fn default() -> Self {
|
||||
Self { settings: ColorSettings::default(), rgb: RgbSpace::Srgb, cmyk: CmykSpace::Generic(generic()) }
|
||||
Self { settings: ColorSettings::default(), rgb: RgbSpace::Srgb, cmyk: default_cmyk() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,40 +324,59 @@ impl Cms {
|
||||
|
||||
/// The working CMYK space as an ICC profile, for converting CMYK pixels in bulk. `None` for
|
||||
/// Device CMYK, which has no profile (the `(1−c)(1−k)` formula).
|
||||
pub fn cmyk_icc_profile(&self) -> Option<moxcms::ColorProfile> {
|
||||
pub fn cmyk_icc_profile(&self) -> Option<Arc<craft_cms::Profile>> {
|
||||
match &self.cmyk {
|
||||
CmykSpace::Generic(_) => {
|
||||
static P: OnceLock<Option<moxcms::ColorProfile>> = OnceLock::new();
|
||||
P.get_or_init(|| moxcms::ColorProfile::new_from_slice(icc_out::generic_cmyk_icc()).ok()).clone()
|
||||
static P: OnceLock<Option<Arc<craft_cms::Profile>>> = OnceLock::new();
|
||||
P.get_or_init(|| icc_out::generic_cmyk_profile().ok().map(|p| Arc::new(p.clone()))).clone()
|
||||
}
|
||||
CmykSpace::Device => None,
|
||||
CmykSpace::Icc(p) => Some(p.profile().clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The PDF/X output intent for the working CMYK space. Device CMYK has no profile and is
|
||||
/// described by the Generic CMYK one.
|
||||
pub fn output_intent(&self) -> Result<OutputIntent, CmsError> {
|
||||
Ok(match &self.cmyk {
|
||||
CmykSpace::Icc(p) if p.name == COATED_FOGRA39 => {
|
||||
OutputIntent { icc: Cow::Borrowed(craft_cms::builtin::COATED_FOGRA39_ICC), identifier: "FOGRA39", condition: COATED_FOGRA39.into() }
|
||||
}
|
||||
CmykSpace::Icc(p) => OutputIntent { icc: Cow::Owned(p.profile().to_bytes().to_vec()), identifier: "Custom", condition: p.name.clone() },
|
||||
CmykSpace::Generic(_) | CmykSpace::Device => {
|
||||
OutputIntent { icc: Cow::Borrowed(icc_out::generic_cmyk_icc()?), identifier: "Custom", condition: GENERIC_CMYK.into() }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Working RGB → display sRGB.
|
||||
pub fn rgb_to_srgb(&self, rgb: [f32; 3]) -> [f32; 3] {
|
||||
match &self.rgb {
|
||||
RgbSpace::Srgb => rgb,
|
||||
RgbSpace::Icc(p) => p.to_srgb(&rgb, Intent::RelativeColorimetric).unwrap_or(rgb),
|
||||
RgbSpace::Icc(p) => p.to_srgb(&rgb, Intent::RelativeColorimetric, false).unwrap_or(rgb),
|
||||
}
|
||||
}
|
||||
/// Display sRGB → working RGB.
|
||||
pub fn srgb_to_rgb(&self, srgb: [f32; 3]) -> [f32; 3] {
|
||||
match &self.rgb {
|
||||
RgbSpace::Srgb => srgb,
|
||||
RgbSpace::Icc(p) => p.from_srgb(srgb, Intent::RelativeColorimetric).map(|v| [v[0], v[1], v[2]]).unwrap_or(srgb),
|
||||
RgbSpace::Icc(p) => match p.from_srgb(srgb, Intent::RelativeColorimetric, false).as_deref() {
|
||||
Some(&[r, g, b]) => [r, g, b],
|
||||
_ => srgb,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn cmyk_to_srgb_in(space: &CmykSpace, cmyk: [f32; 4], absolute: bool) -> [f32; 3] {
|
||||
/// `absolute` simulates the paper colour (absolute colorimetric, no black point
|
||||
/// compensation); otherwise relative colorimetric with `bpc`.
|
||||
fn cmyk_to_srgb_in(space: &CmykSpace, cmyk: [f32; 4], absolute: bool, bpc: bool) -> [f32; 3] {
|
||||
let cmyk = cmyk.map(|v| v.clamp(0.0, 1.0));
|
||||
match space {
|
||||
CmykSpace::Generic(g) => g.to_srgb(cmyk, absolute),
|
||||
CmykSpace::Device => naive_cmyk_to_rgb(cmyk),
|
||||
CmykSpace::Icc(p) => {
|
||||
let i = if absolute { Intent::AbsoluteColorimetric } else { Intent::RelativeColorimetric };
|
||||
p.to_srgb(&cmyk, i).unwrap_or_else(|| naive_cmyk_to_rgb(cmyk))
|
||||
let (i, bpc) = if absolute { (Intent::AbsoluteColorimetric, false) } else { (Intent::RelativeColorimetric, bpc) };
|
||||
p.to_srgb(&cmyk, i, bpc).unwrap_or_else(|| naive_cmyk_to_rgb(cmyk))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -313,13 +385,13 @@ impl Cms {
|
||||
match space {
|
||||
CmykSpace::Generic(g) => g.from_srgb(srgb, intent, bpc),
|
||||
CmykSpace::Device => naive_rgb_to_cmyk(srgb),
|
||||
CmykSpace::Icc(p) => p.from_srgb(srgb, intent).map(|v| [v[0], v[1], v[2], v[3]]).unwrap_or_else(|| naive_rgb_to_cmyk(srgb)),
|
||||
CmykSpace::Icc(p) => cmyk4(p.from_srgb(srgb, intent, bpc)).unwrap_or_else(|| naive_rgb_to_cmyk(srgb)),
|
||||
}
|
||||
}
|
||||
|
||||
/// CMYK in the working space → display sRGB (`absolute` simulates the paper colour).
|
||||
pub fn cmyk_to_srgb(&self, cmyk: [f32; 4], absolute: bool) -> [f32; 3] {
|
||||
Self::cmyk_to_srgb_in(&self.cmyk, cmyk, absolute)
|
||||
Self::cmyk_to_srgb_in(&self.cmyk, cmyk, absolute, self.settings.bpc)
|
||||
}
|
||||
/// Display sRGB → working CMYK with `intent`.
|
||||
pub fn srgb_to_cmyk(&self, srgb: [f32; 3], intent: Intent) -> [f32; 4] {
|
||||
@@ -327,16 +399,19 @@ impl Cms {
|
||||
}
|
||||
/// Media-relative Lab of a working-space CMYK colour.
|
||||
pub fn cmyk_to_lab(&self, cmyk: [f32; 4]) -> Lab {
|
||||
let cmyk = cmyk.map(|v| v.clamp(0.0, 1.0));
|
||||
match &self.cmyk {
|
||||
CmykSpace::Generic(g) => g.to_lab_rel(cmyk.map(|v| v.clamp(0.0, 1.0))),
|
||||
_ => lab::srgb_to_lab(self.cmyk_to_srgb(cmyk, false)),
|
||||
CmykSpace::Generic(g) => g.to_lab_rel(cmyk),
|
||||
CmykSpace::Icc(p) => p.to_lab(&cmyk).unwrap_or_else(|| lab::srgb_to_lab(naive_cmyk_to_rgb(cmyk))),
|
||||
CmykSpace::Device => lab::srgb_to_lab(self.cmyk_to_srgb(cmyk, false)),
|
||||
}
|
||||
}
|
||||
/// Lab → working CMYK with `intent`.
|
||||
pub fn lab_to_cmyk(&self, l: Lab, intent: Intent) -> [f32; 4] {
|
||||
match &self.cmyk {
|
||||
CmykSpace::Generic(g) => g.from_lab(l, intent, self.settings.bpc),
|
||||
_ => self.srgb_to_cmyk(lab::lab_to_srgb(l), intent),
|
||||
CmykSpace::Icc(p) => cmyk4(p.from_lab(l, intent, self.settings.bpc)).unwrap_or_else(|| self.srgb_to_cmyk(lab::lab_to_srgb(l), intent)),
|
||||
CmykSpace::Device => self.srgb_to_cmyk(lab::lab_to_srgb(l), intent),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,10 +477,11 @@ impl Cms {
|
||||
match &self.cmyk {
|
||||
CmykSpace::Generic(g) => g.gamut_error(src),
|
||||
CmykSpace::Device => 0.0,
|
||||
CmykSpace::Icc(_) => {
|
||||
let back = self.cmyk_to_srgb(self.srgb_to_cmyk(srgb, Intent::RelativeColorimetric), false);
|
||||
delta_e2000(src, lab::srgb_to_lab(back))
|
||||
}
|
||||
// The colour sent to the press (relative colorimetric, clipped) and measured back.
|
||||
CmykSpace::Icc(p) => match p.from_srgb(srgb, Intent::RelativeColorimetric, false).and_then(|cmyk| p.to_lab(&cmyk)) {
|
||||
Some(back) => delta_e2000(src, back),
|
||||
None => 0.0,
|
||||
},
|
||||
}
|
||||
}
|
||||
_ => 0.0,
|
||||
@@ -432,7 +508,7 @@ impl Cms {
|
||||
ProofTarget::Cmyk(name) => match cmyk_space(name) {
|
||||
Ok(sp) => {
|
||||
let cmyk = Self::srgb_to_cmyk_in(&sp, srgb, proof.intent, self.settings.bpc);
|
||||
Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper)
|
||||
Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper, self.settings.bpc)
|
||||
}
|
||||
Err(_) => srgb,
|
||||
},
|
||||
@@ -443,7 +519,7 @@ impl Cms {
|
||||
pub fn proof_cmyk_to_srgb(&self, cmyk: [f32; 4], proof: &ProofSetup) -> [f32; 3] {
|
||||
match &proof.target {
|
||||
ProofTarget::Cmyk(name) => match cmyk_space(name) {
|
||||
Ok(sp) => Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper),
|
||||
Ok(sp) => Self::cmyk_to_srgb_in(&sp, cmyk, proof.simulate_paper, self.settings.bpc),
|
||||
Err(_) => self.cmyk_to_srgb(cmyk, proof.simulate_paper),
|
||||
},
|
||||
_ => self.cmyk_to_srgb(cmyk, proof.simulate_paper),
|
||||
@@ -472,6 +548,13 @@ fn clear_proof_cache() {
|
||||
PROOF_CACHE.lock().unwrap_or_else(|e| e.into_inner()).clear();
|
||||
}
|
||||
|
||||
fn cmyk4(v: Option<Vec<f32>>) -> Option<[f32; 4]> {
|
||||
match v?.as_slice() {
|
||||
&[c, m, y, k] => Some([c, m, y, k]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn naive_cmyk_to_rgb([c, m, y, k]: [f32; 4]) -> [f32; 3] {
|
||||
[(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)]
|
||||
}
|
||||
|
||||
@@ -23,22 +23,83 @@ fn intents_parse_and_name() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn srgb_via_moxcms_matches_analytic() {
|
||||
// The built-in sRGB ICC profile through moxcms agrees with our analytic sRGB maths.
|
||||
fn srgb_profile_matches_analytic() {
|
||||
// The built-in sRGB ICC profile agrees with our analytic sRGB maths.
|
||||
let p = icc::builtin_rgb(SRGB).unwrap();
|
||||
for rgb in [[0.2, 0.5, 0.8], [1.0, 0.0, 0.0], [0.5, 0.5, 0.5]] {
|
||||
let out = p.to_srgb(&rgb, Intent::RelativeColorimetric).unwrap();
|
||||
let out = p.to_srgb(&rgb, Intent::RelativeColorimetric, false).unwrap();
|
||||
assert!(de(out, rgb) < 0.5, "{rgb:?} → {out:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// sRGB 0..255 of CMYK percentages.
|
||||
fn shown(c: &Cms, [cy, m, y, k]: [f32; 4]) -> [f32; 3] {
|
||||
c.display_rgb(&Color::cmyk(cy / 100.0, m / 100.0, y / 100.0, k / 100.0)).map(|v| v * 255.0)
|
||||
}
|
||||
|
||||
/// Swatches in the default CMYK space (Coated FOGRA39), shown relative colorimetric with black
|
||||
/// point compensation, and the sRGB they print as on that press.
|
||||
const FOGRA39_ANCHORS: [([f32; 4], [f32; 3]); 4] = [
|
||||
([0.0, 50.0, 100.0, 0.0], [243.0, 146.0, 0.0]),
|
||||
([0.0, 60.0, 100.0, 0.0], [239.0, 123.0, 0.0]),
|
||||
([0.0, 0.0, 0.0, 100.0], [28.0, 28.0, 27.0]),
|
||||
([100.0, 100.0, 0.0, 0.0], [48.0, 38.0, 131.0]),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn default_cmyk_swatches_show_as_coated_fogra39() {
|
||||
assert_eq!(ColorSettings::default().cmyk, COATED_FOGRA39);
|
||||
for c in [Cms::default(), cms()] {
|
||||
for (cmyk, want) in FOGRA39_ANCHORS {
|
||||
let got = shown(&c, cmyk);
|
||||
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 2.0), "{cmyk:?}: {got:?} vs {want:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_cmyk_names_still_load() {
|
||||
for name in [GENERIC_CMYK, DEVICE_CMYK, COATED_FOGRA39] {
|
||||
let s = ColorSettings { cmyk: name.into(), ..Default::default() };
|
||||
assert!(Cms::new(&s).is_ok(), "{name}");
|
||||
assert_eq!(s.with_known_profiles(), s);
|
||||
}
|
||||
// The names of the profiles the built-in stands in for show the same colours.
|
||||
let adobe = Cms::new(&ColorSettings { cmyk: "Coated FOGRA39 (ISO 12647-2:2004)".into(), ..Default::default() }).unwrap();
|
||||
assert_eq!(shown(&adobe, [0.0, 50.0, 100.0, 0.0]), shown(&cms(), [0.0, 50.0, 100.0, 0.0]));
|
||||
// A related press condition with a different ink limit has no stand-in.
|
||||
let iso300 = ColorSettings { cmyk: "ISO Coated v2 300% (ECI)".into(), ..Default::default() };
|
||||
assert!(matches!(Cms::new(&iso300), Err(CmsError::UnknownProfile(_))));
|
||||
// A name that isn't available falls back to the default, keeping the other settings.
|
||||
let saved = ColorSettings { rgb: ADOBE_RGB.into(), cmyk: "Some Press (gone)".into(), bpc: false, ..Default::default() };
|
||||
assert!(Cms::new(&saved).is_err());
|
||||
let loaded = saved.with_known_profiles();
|
||||
assert_eq!(loaded, ColorSettings { rgb: ADOBE_RGB.into(), cmyk: COATED_FOGRA39.into(), bpc: false, ..Default::default() });
|
||||
assert!(Cms::new(&loaded).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_intent_follows_the_working_cmyk_space() {
|
||||
let fogra = cms().output_intent().unwrap();
|
||||
assert_eq!((fogra.identifier, fogra.condition.as_str()), ("FOGRA39", COATED_FOGRA39));
|
||||
assert_eq!(&*fogra.icc, craft_cms::builtin::COATED_FOGRA39_ICC);
|
||||
for name in [GENERIC_CMYK, DEVICE_CMYK] {
|
||||
let g = Cms::new(&ColorSettings { cmyk: name.into(), ..Default::default() }).unwrap().output_intent().unwrap();
|
||||
assert_eq!((g.identifier, g.condition.as_str()), ("Custom", GENERIC_CMYK), "{name}");
|
||||
assert_eq!(&*g.icc, icc_out::generic_cmyk_icc().unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paper_white_and_rich_black() {
|
||||
let c = cms();
|
||||
assert_eq!(c.srgb_to_cmyk([1.0, 1.0, 1.0], Intent::RelativeColorimetric), [0.0; 4]);
|
||||
for intent in [Intent::Perceptual, Intent::RelativeColorimetric, Intent::Saturation] {
|
||||
assert_eq!(c.lab_to_cmyk(lab::Lab::new(100.0, 0.0, 0.0), intent), [0.0; 4], "{intent:?}");
|
||||
}
|
||||
let k = c.srgb_to_cmyk([0.0, 0.0, 0.0], Intent::RelativeColorimetric);
|
||||
assert!(k[3] > 0.8, "black generation puts most of the black in K: {k:?}");
|
||||
assert!(k.iter().sum::<f32>() <= 3.0 + 1e-3, "TAC limit: {k:?}");
|
||||
assert!(k.iter().sum::<f32>() <= 3.3 + 1e-3, "Coated FOGRA39 total ink limit (330 %): {k:?}");
|
||||
assert!(k[0] > 0.3 && k[1] > 0.3 && k[2] > 0.3, "rich black: {k:?}");
|
||||
assert_eq!(c.cmyk_to_srgb([0.0; 4], false), [1.0, 1.0, 1.0]);
|
||||
}
|
||||
@@ -112,6 +173,10 @@ fn simulate_paper_tints_white() {
|
||||
let rel = c.cmyk_to_srgb([0.0; 4], false);
|
||||
let abs = c.cmyk_to_srgb([0.0; 4], true);
|
||||
assert_eq!(rel, [1.0; 3]);
|
||||
// FOGRA39 paper is slightly blue (b* -2); Generic CMYK's slightly warm.
|
||||
assert!(abs.iter().all(|v| *v < 0.97) && abs[2] > abs[0], "paper is darker and slightly blue: {abs:?}");
|
||||
let g = Cms::new(&ColorSettings { cmyk: GENERIC_CMYK.into(), ..Default::default() }).unwrap();
|
||||
let abs = g.cmyk_to_srgb([0.0; 4], true);
|
||||
assert!(abs[0] < 0.98 && abs[2] < abs[0], "paper is darker and slightly warm: {abs:?}");
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ image = { workspace = true }
|
||||
tiff = { workspace = true }
|
||||
resvg = { workspace = true }
|
||||
log = { workspace = true }
|
||||
moxcms = "0.8"
|
||||
craft-cms = { workspace = true }
|
||||
zune-core = { version = "0.5", default-features = false }
|
||||
zune-jpeg = "0.5"
|
||||
|
||||
|
||||
+51
-283
@@ -2,261 +2,27 @@
|
||||
//! 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`.
|
||||
//! Colour management is craft-cms's ([`Profile`], [`Transform`], [`TransformOptions`],
|
||||
//! [`Intent`], [`Builtin`]); black point compensation is its, as set in the colour settings.
|
||||
//!
|
||||
//! 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 std::sync::Arc;
|
||||
|
||||
use designcraft_color::{Color, cms::Cms};
|
||||
use image::RgbaImage;
|
||||
use moxcms::{ColorProfile, DataColorSpace, Layout, RenderingIntent, Transform8BitExecutor};
|
||||
|
||||
pub use designcraft_color::cms::Intent;
|
||||
pub use craft_cms::{Builtin, ColorSpace, Intent, Profile, Transform, TransformOptions};
|
||||
|
||||
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<Builtin>,
|
||||
/// Identifies the profile for [`Transform::cached`]; `None` when it can't be encoded.
|
||||
key: Option<u64>,
|
||||
}
|
||||
|
||||
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<Self, ProfileError> {
|
||||
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<Transform8BitExecutor>,
|
||||
src_channels: usize,
|
||||
dst_channels: usize,
|
||||
/// Black point compensation, applied to the sRGB output.
|
||||
bpc: Option<Box<[u8; 256]>>,
|
||||
}
|
||||
|
||||
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<Box<[u8; 256]>> {
|
||||
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, ProfileError> {
|
||||
Self::with_options(src, dst, TransformOptions { intent, black_point_compensation: false })
|
||||
}
|
||||
|
||||
pub fn with_options(src: &Profile, dst: &Profile, options: TransformOptions) -> Result<Self, ProfileError> {
|
||||
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<Arc<Self>, ProfileError> {
|
||||
static CACHE: Mutex<Vec<((u64, u64, TransformOptions), Arc<Transform>)>> = 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(())
|
||||
}
|
||||
}
|
||||
/// Pixels per conversion call when converting in place (each chunk is copied once).
|
||||
const CHUNK: usize = 1 << 16;
|
||||
|
||||
/// Decoded 8-bit pixels and what their values mean.
|
||||
pub enum Pixels {
|
||||
@@ -272,12 +38,12 @@ pub enum Pixels {
|
||||
/// (`None`: the profile-free formula, for Device CMYK), and the transform options.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DisplayColour {
|
||||
working_cmyk: Option<Profile>,
|
||||
working_cmyk: Option<Arc<Profile>>,
|
||||
options: TransformOptions,
|
||||
}
|
||||
|
||||
impl DisplayColour {
|
||||
pub fn new(working_cmyk: Option<Profile>, options: TransformOptions) -> Self {
|
||||
pub fn new(working_cmyk: Option<Arc<Profile>>, options: TransformOptions) -> Self {
|
||||
Self { working_cmyk, options }
|
||||
}
|
||||
|
||||
@@ -289,7 +55,7 @@ impl DisplayColour {
|
||||
|
||||
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 })
|
||||
Self::new(cms.cmyk_icc_profile(), TransformOptions { intent: s.intent.into(), bpc: s.bpc, ..TransformOptions::default() })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,45 +65,55 @@ pub fn to_display_rgba(px: Pixels, embedded_icc: Option<&[u8]>, display: &Displa
|
||||
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)
|
||||
if let Some(x) = own(ColorSpace::Rgb)
|
||||
&& !keeps_rgb(&x)
|
||||
{
|
||||
in_place(&mut img, &x, 3);
|
||||
in_place(&mut img, &x);
|
||||
}
|
||||
Some(img)
|
||||
}
|
||||
Pixels::Gray(mut img) => {
|
||||
if let Some(x) = own(Space::Gray)
|
||||
if let Some(x) = own(ColorSpace::Gray)
|
||||
&& !keeps_gray(&x)
|
||||
{
|
||||
in_place(&mut img, &x, 1);
|
||||
in_place(&mut img, &x);
|
||||
}
|
||||
Some(img)
|
||||
}
|
||||
Pixels::Cmyk(raster) => {
|
||||
let xf = own(Space::Cmyk).or_else(|| to_srgb(display.working_cmyk.as_ref()?, display.options));
|
||||
let xf = own(ColorSpace::Cmyk).or_else(|| to_srgb(display.working_cmyk.as_deref()?, display.options));
|
||||
cmyk(&raster, xf.as_deref())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse(icc: &[u8], pixels: Space) -> Option<Profile> {
|
||||
fn parse(icc: &[u8], pixels: ColorSpace) -> Option<Profile> {
|
||||
if icc.len() > MAX_ICC_BYTES {
|
||||
log::warn!("embedded ICC profile of {} bytes is too large: ignored", icc.len());
|
||||
return None;
|
||||
}
|
||||
match Profile::parse(icc) {
|
||||
Ok(p) if p.space() == pixels => Some(p),
|
||||
Ok(p) if p.color_space == pixels => Some(p),
|
||||
Ok(p) => {
|
||||
log::warn!("embedded ICC profile is for {:?}, the pixels are {pixels:?}: ignored", p.space());
|
||||
log::warn!("embedded ICC profile is for {:?}, the pixels are {pixels:?}: ignored", p.color_space);
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("embedded {e}: ignored");
|
||||
log::warn!("embedded ICC profile can't be read ({e}): ignored");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A transform from `src` to display sRGB, reusing one built earlier for the same profiles and
|
||||
/// options (CMYK transforms build a large table).
|
||||
fn to_srgb(src: &Profile, options: TransformOptions) -> Option<Arc<Transform>> {
|
||||
match Transform::cached(src, &Profile::builtin(Builtin::Srgb), options) {
|
||||
Ok(x) => Some(x),
|
||||
match craft_cms::cached(src, Builtin::Srgb.profile(), options) {
|
||||
Ok(x) if x.inputs() == src.channels() && x.outputs() == 3 => Some(x),
|
||||
Ok(_) => {
|
||||
log::warn!("image ICC profile to sRGB: unexpected channel counts; profile ignored");
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("image ICC profile to sRGB: {e}; profile ignored");
|
||||
None
|
||||
@@ -345,36 +121,43 @@ fn to_srgb(src: &Profile, options: TransformOptions) -> Option<Arc<Transform>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert interleaved pixels (`src_stride` / `dst_stride` values per pixel, colour channels
|
||||
/// first; the rest of `dst` is left alone). `false` when the buffers don't fit the transform.
|
||||
fn convert(x: &Transform, src: &[u8], src_stride: usize, dst: &mut [u8], dst_stride: usize) -> bool {
|
||||
match x.convert_u8(src, src_stride, dst, dst_stride, false) {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
log::warn!("image ICC conversion: {e}; profile ignored");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<u8> = 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)
|
||||
convert(x, &src, 3, &mut dst, 3) && src.iter().zip(&dst).all(|(a, b)| a.abs_diff(*b) <= 1)
|
||||
}
|
||||
|
||||
fn keeps_gray(x: &Transform) -> bool {
|
||||
let src: Vec<u8> = (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(x, &src, 1, &mut dst, 3) && 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];
|
||||
/// Convert RGBA pixels whose first channels (RGB, or grey for a grey transform) hold the source
|
||||
/// values; alpha is kept.
|
||||
fn in_place(img: &mut RgbaImage, x: &Transform) {
|
||||
let mut src = Vec::with_capacity(CHUNK * 4);
|
||||
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() {
|
||||
src.extend_from_slice(chunk);
|
||||
if !convert(x, &src, 4, chunk, 4) {
|
||||
return;
|
||||
}
|
||||
for (p, rgb) in px.iter_mut().zip(out.as_chunks::<3>().0) {
|
||||
p[..3].copy_from_slice(rgb);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,22 +172,7 @@ fn cmyk(r: &CmykRaster, x: Option<&Transform>) -> Option<RgbaImage> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
let converted = x.is_some_and(|x| convert(x, &r.cmyk, 4, &mut rgba, 4));
|
||||
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;
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
use designcraft_color::cms::{self, Cms, ColorSettings};
|
||||
use image::{ImageEncoder, Rgba};
|
||||
use moxcms::ColorProfile;
|
||||
|
||||
use super::*;
|
||||
use crate::decode_rgba;
|
||||
|
||||
/// Generic CMYK as the working space.
|
||||
fn generic(bpc: bool) -> Cms {
|
||||
Cms::new(&ColorSettings { cmyk: cms::GENERIC_CMYK.into(), bpc, ..ColorSettings::default() }).unwrap()
|
||||
}
|
||||
|
||||
/// 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)
|
||||
generic(false).cmyk_to_srgb(cmyk, false).map(|v| v * 255.0)
|
||||
}
|
||||
|
||||
/// The default colour settings without black point compensation.
|
||||
@@ -15,6 +19,10 @@ fn no_bpc() -> DisplayColour {
|
||||
DisplayColour::of(&Cms::new(&ColorSettings { bpc: false, ..ColorSettings::default() }).unwrap())
|
||||
}
|
||||
|
||||
fn icc(b: Builtin) -> Vec<u8> {
|
||||
b.profile().to_bytes().to_vec()
|
||||
}
|
||||
|
||||
/// 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 };
|
||||
@@ -93,7 +101,7 @@ fn png_with_icc(w: u32, h: u32, px: &[u8], icc: Option<Vec<u8>>) -> Vec<u8> {
|
||||
}
|
||||
|
||||
fn adobe_rgb_icc() -> Vec<u8> {
|
||||
ColorProfile::new_adobe_rgb().encode().unwrap()
|
||||
icc(Builtin::AdobeRgbCompat)
|
||||
}
|
||||
|
||||
/// sRGB 0..255 of an Adobe RGB colour through the colour crate's float transform.
|
||||
@@ -123,11 +131,36 @@ fn cmyk_jpeg_goes_through_the_working_cmyk_space() {
|
||||
assert_eq!(decode_rgba(&plain).unwrap().get_pixel(7, 7).0, cyan);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmyk_without_a_profile_shows_as_the_default_swatches_do() {
|
||||
// Default settings: Coated FOGRA39, relative colorimetric with black point compensation.
|
||||
let display = DisplayColour::of(&Cms::default());
|
||||
for (inks, want) in [
|
||||
([0, 50, 100, 0], [243.0, 146.0, 0.0]),
|
||||
([0, 60, 100, 0], [239.0, 123.0, 0.0]),
|
||||
([0, 0, 0, 100], [28.0, 28.0, 27.0]),
|
||||
([100, 100, 0, 0], [48.0, 38.0, 131.0]),
|
||||
] {
|
||||
let inks = inks.map(|v: u16| u8::try_from((v * 255 + 50) / 100).unwrap());
|
||||
let p = shown(inks, &display);
|
||||
let swatch = Cms::default().display_rgb(&Color::cmyk(
|
||||
f32::from(inks[0]) / 255.0,
|
||||
f32::from(inks[1]) / 255.0,
|
||||
f32::from(inks[2]) / 255.0,
|
||||
f32::from(inks[3]) / 255.0,
|
||||
));
|
||||
assert!(near(p, want, 2.0), "{inks:?}: {p:?} vs {want:?}");
|
||||
assert!(near(p, swatch.map(|v| v * 255.0), 2.0), "{inks:?}: {p:?} vs swatch {swatch:?}");
|
||||
// A CMYK JPEG without a profile (and without an Adobe marker: inks as stored) too.
|
||||
assert_eq!(decode_rgba(&solid_jpeg(inks, None)).unwrap().get_pixel(0, 0).0, shown(inks, &DisplayColour::active()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn working_cmyk_follows_the_model_and_black_point_compensation() {
|
||||
// The working space's ICC table agrees with the Generic CMYK model itself.
|
||||
// Generic CMYK's ICC table agrees with the 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 p = shown(inks, &DisplayColour::of(&generic(false)));
|
||||
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:?}");
|
||||
@@ -151,9 +184,9 @@ fn cmyk_uses_its_own_profile_before_the_working_space() {
|
||||
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 icc = cms::icc_out::generic_cmyk_icc().unwrap();
|
||||
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(0, 0).0, shown([255, 0, 0, 0], &DisplayColour::of(&generic(true))));
|
||||
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();
|
||||
@@ -176,7 +209,7 @@ fn rgb_with_an_embedded_profile_is_converted_to_srgb() {
|
||||
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()));
|
||||
let srgb = png_with_icc(2, 1, &px, Some(icc(Builtin::Srgb)));
|
||||
assert_eq!(decode_rgba(&srgb).unwrap().as_raw(), &px);
|
||||
assert_eq!(decode_rgba(&png_with_icc(2, 1, &px, None)).unwrap().as_raw(), &px);
|
||||
}
|
||||
@@ -184,7 +217,7 @@ fn rgb_with_an_embedded_profile_is_converted_to_srgb() {
|
||||
#[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 icc = craft_cms::builtin::linear_gray().to_bytes().to_vec();
|
||||
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:?}");
|
||||
@@ -194,7 +227,7 @@ fn gray_with_an_embedded_profile_is_converted() {
|
||||
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 cmyk = cms::icc_out::generic_cmyk_icc().unwrap().to_vec();
|
||||
let mut huge = adobe.clone();
|
||||
huge.resize(MAX_ICC_BYTES + 1, 0);
|
||||
let mut lying = adobe.clone();
|
||||
|
||||
@@ -674,7 +674,7 @@ mod tests {
|
||||
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();
|
||||
psd.icc = craft_cms::Builtin::AdobeRgbCompat.profile().to_bytes().to_vec();
|
||||
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]);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! PDF/X-4: the output intent (our Generic CMYK profile), the PDF/X version in the document
|
||||
//! PDF/X-4: the output intent (the working CMYK space's profile), the PDF/X version in the document
|
||||
//! information and XMP, written as an incremental update of krilla's PDF 1.6 output; and a
|
||||
//! check of the requirements this exporter is responsible for.
|
||||
|
||||
@@ -46,19 +46,19 @@ pub fn make_pdfx4(pdf: &[u8], title: &str) -> Option<Vec<u8>> {
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
};
|
||||
// The output profile and the output intent.
|
||||
let icc = designcraft_color::cms::icc_out::generic_cmyk_icc();
|
||||
let intent = designcraft_color::cms::active().output_intent().ok()?;
|
||||
let (icc_id, oi_id) = (size, size + 1);
|
||||
let mut s = format!("<</N 4/Length {}>>\nstream\n", icc.len()).into_bytes();
|
||||
s.extend_from_slice(icc);
|
||||
let mut s = format!("<</N 4/Length {}>>\nstream\n", intent.icc.len()).into_bytes();
|
||||
s.extend_from_slice(&intent.icc);
|
||||
s.extend_from_slice(b"\nendstream");
|
||||
put(&mut out, icc_id, &s);
|
||||
let cond = designcraft_color::cms::GENERIC_CMYK;
|
||||
let cond = &intent.condition;
|
||||
put(
|
||||
&mut out,
|
||||
oi_id,
|
||||
format!(
|
||||
"<</Type/OutputIntent/S/GTS_PDFX/OutputConditionIdentifier{}/OutputCondition{}/Info{}/RegistryName(http://www.color.org)/DestOutputProfile {icc_id} 0 R>>",
|
||||
lit("Custom"),
|
||||
lit(intent.identifier),
|
||||
lit(cond),
|
||||
lit(cond)
|
||||
)
|
||||
|
||||
@@ -175,7 +175,9 @@ fn standards() {
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
assert!(crate::check_pdfx4(&r.bytes).is_empty());
|
||||
let text = String::from_utf8_lossy(&r.bytes).to_string();
|
||||
assert!(text.contains("/OutputIntents[") && text.contains("DesignCraft Generic CMYK"));
|
||||
// The default working CMYK space is the built-in Coated FOGRA39.
|
||||
assert!(text.contains("/OutputIntents[") && text.contains("/OutputConditionIdentifier(FOGRA39)"), "output intent");
|
||||
assert!(text.contains(r"/OutputCondition(Coated FOGRA39 \(fitted, craft-cms\))"), "output condition");
|
||||
// A plain export fails the checks; both read with the same pages.
|
||||
let plain = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap();
|
||||
assert_eq!(hayro_syntax::Pdf::new(r.bytes.clone()).unwrap().pages().len(), hayro_syntax::Pdf::new(plain.clone()).unwrap().pages().len());
|
||||
|
||||
@@ -1022,7 +1022,7 @@ impl DesignApp {
|
||||
if !self.color_applied {
|
||||
self.color_applied = true;
|
||||
if let Some(cs) = self.ui.color_settings.clone() {
|
||||
let _ = designcraft_color::cms::set_active(&cs);
|
||||
let _ = designcraft_color::cms::set_active(&cs.with_known_profiles());
|
||||
}
|
||||
}
|
||||
let scale = self.ui.ui_scale.clamp(0.5, 3.0);
|
||||
|
||||
@@ -376,10 +376,10 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
|
||||
- Right-to-left text (World-Ready basics): right-to-left runs are shaped right to left, lines are reordered by the Unicode Bidirectional Algorithm (matched against the reference implementation), Paragraph Direction (`type.para {direction}`, Paragraph panel, IDML ParagraphDirection, EPUB `dir`). Digits (`digits`: Default, Arabic, Hindi, Farsi, Native by language; Character panel for right-to-left text; IDML DigitsType) draw 0–9 in another script. Kashidas (`kashidas` paragraph attribute, on by default; IDML Kashidas): justified Arabic lines stretch at one join per word (after seen/sad when possible, never inside lam–alef) with a drawn tatweel before word spaces widen. Carets follow the drawing: the caret before a right-to-left glyph sits at its right edge, arrow keys move as drawn (by words in the line's direction), clicks find the nearest drawn caret position and selections highlight the selected glyphs. Binding: Right to Left (Document Setup, `layout.documentSetup {binding}`, IDML PageBinding) makes page 1 a left page and lays spreads out right to left.
|
||||
- Interface language (Preferences ▸ General ▸ Interface Language, `app.language`; each language listed by its own name): German, French, Spanish, Italian, Japanese, Simplified Chinese and Arabic menu titles, common menu items and panel names (our own translations). Common dialog, panel and tooltip labels are also localized, with English fallback for untranslated strings. Arabic translations cover every key in the shared interface translation table, including menu commands and utility labels. The Type menu's Story Direction, Horizontal/Vertical, Tate-Chu-Yoko, Ruby… and Kenten are translated too. Arabic labels use Unicode bidi run ordering and contextual shaping; welcome and new-document form layouts follow RTL direction independently of document binding. CJK and Arabic glyphs come from [craft-fonts](https://github.com/storytold/craft-fonts) when built with `CRAFT_FONTS_DIR` (all releases): BIZ UDPGothic and Noto Sans CJK SC for the UI (Chinese forms first in the Chinese interface), Noto Sans Arabic, and Mincho for Japanese documents (the web build embeds BIZ UDPGothic only); otherwise from system fonts. Ukrainian covers all 1,430 existing catalog keys, including publishing dialogs and built-in names, with persisted selection, grammatical count captions, English fallback and bundled Cyrillic UI glyph coverage.
|
||||
- Page transitions (`page.transition/transitions`, Page Transitions panel): twelve transition types with speed and direction per spread, written into exported PDFs as `/Trans` entries through an incremental update.
|
||||
- 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.
|
||||
- PDF/X-4 export (`file.exportPdf {standard: "x4"}`): an output intent with the working CMYK space's profile (by default craft-cms's Coated FOGRA39 profile, identifier FOGRA39; Generic CMYK and Device CMYK embed our Generic CMYK 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. 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.
|
||||
- Colour management exposed (ICC through the craft-libs `craft-cms` crate): Edit ▸ Color Settings (`color.settings`: RGB/CMYK working spaces, default CMYK Coated FOGRA39 with CMYK swatches shown relative colorimetric with black-point compensation, Generic CMYK and Device CMYK still selectable, 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.
|
||||
|
||||
Reference in New Issue
Block a user