mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-02 06:07:06 -06:00
Fix #320: CMYK images stay CMYK from PDF import to PDF export
A PDF's DeviceCMYK image opened as RGB (the interpreter's pixels as a PNG), so every export wrote it as DeviceRGB or ICC RGB, silently. Images now keep their CMYK samples, as CMYK colours already did: - Model: CMYK images are blobs holding CMYK samples, a four-component JPEG (Adobe's inverted samples) or a Deflate CMYK TIFF. vectorcraft-doc's cmyk module reads their ink amounts (ImageBlob::is_cmyk, ::cmyk, capped at 64 megapixels) and writes the TIFF (ImageBlob::cmyk_tiff). - PDF import: DeviceCMYK, CalCMYK and four-component ICC images keep their samples. A JPEG with Decode [1 0 1 0 1 0 1 0] is kept byte for byte; any other is decoded (8 or 16 bits, its Decode array applied) and stored as a CMYK TIFF. Masked CMYK images and 1/2/4-bit ones open in RGB with a warning. Placing a CMYK TIFF keeps it CMYK too. - PDF export: CMYK images are written in CMYK (DeviceCMYK, or ICC-based with the CMYK profile when colours are tagged: PDF/X-3, PDF/X-4, PDF/A). A CMYK JPEG neither resampled nor recompressed passes through unchanged; others are resampled in CMYK and compressed as ZIP or as a CMYK JPEG (jpeg-encoder, which also replaces the ZIP fallback and its warning for images converted to CMYK). Output conversion treats them like CMYK colours: Preserve Numbers and PDF/X-1a keep their numbers, Convert to Destination converts them to another CMYK profile or to RGB through a 17^4 lookup table (cms::CmykLut, now also behind the renderer's ink table). A CMYK image that can't be decoded is reported in the warnings. - Canvas: CMYK images show through the working CMYK profile, as CMYK fills do, and paint their own inks in ink planes (CMYK raster exports, CMYK blending). SVG export writes TIFF images as PNG. Illustrator's Output › No Conversion keeps colour data as it is (https://helpx.adobe.com/illustrator/using/pdf-options.html). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a18e671817
commit
ab62b5f8e8
+102
-48
@@ -19,9 +19,11 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
use vectorcraft_color::Color;
|
||||
use vectorcraft_color::blend::{cmyk_planes, planes_cmyk};
|
||||
use vectorcraft_color::cms::{self, Cms, ProofLut, lab};
|
||||
use vectorcraft_doc::{ColorMode, Document};
|
||||
use vectorcraft_color::cms::{self, Cms, CmykLut, ProofLut, lab};
|
||||
use vectorcraft_doc::cmyk::Inks;
|
||||
use vectorcraft_doc::{ColorMode, Document, ImageBlob};
|
||||
use vello_cpu::Pixmap;
|
||||
use vello_cpu::color::PremulRgba8;
|
||||
use vello_cpu::peniko;
|
||||
|
||||
use crate::{RenderOptions, Renderer};
|
||||
@@ -117,56 +119,22 @@ thread_local! {
|
||||
static SEPARATED: RefCell<Separated> = RefCell::default();
|
||||
}
|
||||
|
||||
/// Grid points per ink of [`InkLut`].
|
||||
const LUT_N: usize = 17;
|
||||
|
||||
/// Inks → display sRGB through the working CMYK profile: a 17⁴ lookup table of XYZ, interpolated
|
||||
/// quadrilinearly (inks mix close to linearly in light, and out-of-gamut colours are clipped
|
||||
/// after interpolating, not before).
|
||||
struct InkLut {
|
||||
data: Vec<[f32; 3]>,
|
||||
}
|
||||
pub(crate) struct InkLut(CmykLut);
|
||||
|
||||
impl InkLut {
|
||||
fn build(cms: &Cms) -> Self {
|
||||
let s = (LUT_N - 1) as f32;
|
||||
let mut data = Vec::with_capacity(LUT_N.pow(4));
|
||||
for c in 0..LUT_N {
|
||||
for m in 0..LUT_N {
|
||||
for y in 0..LUT_N {
|
||||
for k in 0..LUT_N {
|
||||
data.push(lab::lab_to_xyz(cms.cmyk_to_lab([c, m, y, k].map(|i| i as f32 / s))));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Self { data }
|
||||
Self(CmykLut::build(|inks| {
|
||||
let [x, y, z] = lab::lab_to_xyz(cms.cmyk_to_lab(inks));
|
||||
[x, y, z, 0.0]
|
||||
}))
|
||||
}
|
||||
|
||||
fn apply(&self, inks: [f32; 4]) -> [f32; 3] {
|
||||
const STRIDE: [usize; 4] = [LUT_N * LUT_N * LUT_N, LUT_N * LUT_N, LUT_N, 1];
|
||||
let p = inks.map(|v| v.clamp(0.0, 1.0) * (LUT_N - 1) as f32);
|
||||
let i = p.map(|v| (v as usize).min(LUT_N - 2));
|
||||
let base: usize = (0..4).map(|d| i[d] * STRIDE[d]).sum();
|
||||
let mut out = [0.0f32; 3];
|
||||
for corner in 0..16 {
|
||||
let (mut w, mut at) = (1.0f32, base);
|
||||
for d in 0..4 {
|
||||
let t = p[d] - i[d] as f32;
|
||||
if corner >> d & 1 == 1 {
|
||||
w *= t;
|
||||
at += STRIDE[d];
|
||||
} else {
|
||||
w *= 1.0 - t;
|
||||
}
|
||||
}
|
||||
if w > 0.0 {
|
||||
for (o, v) in out.iter_mut().zip(self.data[at]) {
|
||||
*o += w * v;
|
||||
}
|
||||
}
|
||||
}
|
||||
lab::xyz_to_srgb(out)
|
||||
let [x, y, z, _] = self.0.apply(inks);
|
||||
lab::xyz_to_srgb([x, y, z])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,9 +205,55 @@ pub(crate) fn amounts(cmy: &[u8], k: &[u8]) -> Vec<u8> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// CMYK images as shown, per image key, with the table they were shown through.
|
||||
pub(crate) type CmykImages = HashMap<String, (Arc<InkLut>, Arc<Pixmap>)>;
|
||||
|
||||
/// Ink amounts `inks` shown through `lut` (opaque).
|
||||
fn inks_pixmap(inks: &Inks, lut: &InkLut) -> Option<Pixmap> {
|
||||
let (w, h) = (u16::try_from(inks.width).ok()?, u16::try_from(inks.height).ok()?);
|
||||
// Runs of one colour convert once.
|
||||
let mut last: Option<([u8; 4], PremulRgba8)> = None;
|
||||
let data = inks
|
||||
.data
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|p| match last {
|
||||
Some((k, v)) if k == *p => v,
|
||||
_ => {
|
||||
let [r, g, b] = lut.apply(p.map(|v| v as f32 / 255.0)).map(q);
|
||||
let v = PremulRgba8 { r, g, b, a: 255 };
|
||||
last = Some((*p, v));
|
||||
v
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Some(Pixmap::from_parts(data, w, h))
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
/// Placed image `key` (decoded as `pm`) as painted with `ink`, cached.
|
||||
pub(crate) fn ink_image(&mut self, key: &str, pm: &Arc<Pixmap>, ink: Ink) -> Arc<Pixmap> {
|
||||
/// The pixels of CMYK image `blob` (cached as `cache_key`): its inks shown through the working
|
||||
/// CMYK profile, as CMYK colours are, again when the colour settings change. `None` for
|
||||
/// other images.
|
||||
pub(crate) fn cmyk_image(&mut self, blob: &ImageBlob, cache_key: &str) -> Option<Arc<Pixmap>> {
|
||||
// Other images are cached as they are once decoded: only CMYK ones come here again.
|
||||
let shown = self.cmyk_images.get(cache_key).cloned();
|
||||
if shown.is_none() && !blob.is_cmyk() {
|
||||
return None;
|
||||
}
|
||||
let lut = cached(&DISPLAY, InkLut::build);
|
||||
if let Some((owner, pm)) = shown
|
||||
&& Arc::ptr_eq(&owner, &lut)
|
||||
{
|
||||
return Some(pm);
|
||||
}
|
||||
let pm = Arc::new(inks_pixmap(&blob.cmyk()?, &lut)?);
|
||||
self.cmyk_images.insert(cache_key.to_string(), (lut, pm.clone()));
|
||||
Some(pm)
|
||||
}
|
||||
|
||||
/// Placed image `key` (decoded as `pm`, from `blob`) as painted with `ink`, cached.
|
||||
pub(crate) fn ink_image(&mut self, key: &str, pm: &Arc<Pixmap>, ink: Ink, blob: Option<&ImageBlob>) -> Arc<Pixmap> {
|
||||
if ink == Ink::Display {
|
||||
return pm.clone();
|
||||
}
|
||||
@@ -253,16 +267,27 @@ impl Renderer {
|
||||
{
|
||||
return planes[at].clone();
|
||||
}
|
||||
let planes = [0, 1].map(|i| {
|
||||
// CMYK images paint their own inks; the others are separated.
|
||||
let size = (pm.width() as u32, pm.height() as u32);
|
||||
let inks = blob.filter(|_| self.cmyk_images.contains_key(key)).and_then(ImageBlob::cmyk).filter(|i| (i.width, i.height) == size);
|
||||
let planes = [Ink::Cmy, Ink::K].map(|plane| {
|
||||
let mut out = Pixmap::new(pm.width(), pm.height());
|
||||
if let Some(inks) = &inks {
|
||||
for (o, p) in out.data_mut().iter_mut().zip(inks.data.as_chunks::<4>().0) {
|
||||
let [r, g, b] = plane.plane(p.map(|v| v as f32 / 255.0)).map(q);
|
||||
*o = PremulRgba8 { r, g, b, a: 255 };
|
||||
}
|
||||
return Arc::new(out);
|
||||
}
|
||||
let lut = &luts[(plane == Ink::K) as usize];
|
||||
for (o, s) in out.data_mut().iter_mut().zip(pm.data()) {
|
||||
let a = s.a;
|
||||
if a == 0 {
|
||||
continue;
|
||||
}
|
||||
let un = |v: u8| (v as f32 / a as f32).min(1.0);
|
||||
let [r, g, b] = luts[i].apply([un(s.r), un(s.g), un(s.b)]).map(|v| (v.clamp(0.0, 1.0) * a as f32).round() as u8);
|
||||
*o = vello_cpu::color::PremulRgba8 { r, g, b, a };
|
||||
let [r, g, b] = lut.apply([un(s.r), un(s.g), un(s.b)]).map(|v| (v.clamp(0.0, 1.0) * a as f32).round() as u8);
|
||||
*o = PremulRgba8 { r, g, b, a };
|
||||
}
|
||||
Arc::new(out)
|
||||
});
|
||||
@@ -293,6 +318,35 @@ mod tests {
|
||||
assert!(worst * 255.0 < 2.5, "worst error {} levels", worst * 255.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmyk_images_show_through_the_profile_and_paint_their_own_inks() {
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{Appearance, ImageObject, Node, NodeKind};
|
||||
use vectorcraft_geom::{Affine, Rect, shapes};
|
||||
|
||||
let ink = [0u8, 102, 255, 25];
|
||||
let mut d = Document::new_with_mode(20.0, 10.0, ColorMode::Cmyk);
|
||||
d.images.insert("img".into(), ImageBlob::cmyk_tiff(&Inks::new(2, 2, ink.repeat(4)).unwrap()).unwrap());
|
||||
let l = d.layers[0].id;
|
||||
let im = ImageObject { key: "img".into(), width: 2, height: 2, xf: Affine::scale(5.0), link: None, placement: Default::default() };
|
||||
let n = Node::new(d.alloc_id(), NodeKind::Image(im));
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
// The same inks as a fill beside it.
|
||||
let fill = Color::cmyk(0.0, 0.4, 1.0, 25.0 / 255.0);
|
||||
let n =
|
||||
Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 0.0, 20.0, 10.0)), Appearance::basic(Paint::solid(fill), Paint::None, 0.0));
|
||||
d.insert(Some(l), 1, n).unwrap();
|
||||
let mut r = Renderer::new();
|
||||
let region = Rect::new(0.0, 0.0, 20.0, 10.0);
|
||||
let shown = r.render_region(&d, region, 1.0, true).pixels;
|
||||
let at = |px: &[u8], x: usize| px[(5 * 20 + x) * 4..][..4].to_vec();
|
||||
let (image, fill) = (at(&shown, 5), at(&shown, 15));
|
||||
assert!(image.iter().zip(&fill).all(|(a, b)| a.abs_diff(*b) <= 2), "{image:?} vs {fill:?}");
|
||||
// CMYK exports draw ink amounts: the image's own.
|
||||
let inks = r.render_region_inks(&d, region, 1.0, &RenderOptions::default());
|
||||
assert!(at(&inks, 5).iter().zip(ink).all(|(a, b)| a.abs_diff(b) <= 1), "{:?}", at(&inks, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_colours_and_paper() {
|
||||
// White is paper on both planes; the display plane keeps screen colours exactly.
|
||||
|
||||
@@ -325,6 +325,8 @@ pub struct Renderer {
|
||||
clip_paints: PtrMap<usize, ClipPaintEntry>,
|
||||
/// Placed images as painted in ink planes (see [`ink`]).
|
||||
ink_images: ink::InkImages,
|
||||
/// CMYK images as shown through the working CMYK profile (see [`ink`]).
|
||||
cmyk_images: ink::CmykImages,
|
||||
/// Gradients along or across strokes, keyed like `strokes` (see [`Self::fill_path_gradient`]).
|
||||
stroke_slices: PtrMap<(usize, i32), SliceEntry>,
|
||||
/// The keys and sizes of the recoloured images colour adjustments made in `images`, oldest
|
||||
@@ -407,6 +409,7 @@ impl Renderer {
|
||||
clip_paths: vec![],
|
||||
clip_paints: PtrMap::default(),
|
||||
ink_images: Default::default(),
|
||||
cmyk_images: Default::default(),
|
||||
stroke_slices: PtrMap::default(),
|
||||
adjusted: Default::default(),
|
||||
dim_images: None,
|
||||
@@ -1320,7 +1323,7 @@ impl Renderer {
|
||||
};
|
||||
let pixels = if outline && !f.doc.setup.outline_images { None } else { self.image_pixmap(f.doc, &im.key, &cache_key, outline) };
|
||||
if let Some(pm) = pixels {
|
||||
let pm = if outline { pm } else { self.ink_image(&cache_key, &pm, f.ink) };
|
||||
let pm = if outline { pm } else { self.ink_image(&cache_key, &pm, f.ink, f.doc.images.get(&im.key)) };
|
||||
let sx = im.width as f64 / pm.width().max(1) as f64;
|
||||
let sy = im.height as f64 / pm.height().max(1) as f64;
|
||||
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler: peniko::ImageSampler::default() });
|
||||
@@ -1398,9 +1401,15 @@ impl Renderer {
|
||||
let colour = match self.images.get(cache_key) {
|
||||
Some(p) => p.clone(),
|
||||
None => {
|
||||
let pm = Arc::new(paint::decode_pixmap(&doc.images.get(key)?.bytes)?);
|
||||
self.images.insert(cache_key.to_string(), pm.clone());
|
||||
pm
|
||||
let blob = doc.images.get(key)?;
|
||||
match self.cmyk_image(blob, cache_key) {
|
||||
Some(pm) => pm,
|
||||
None => {
|
||||
let pm = Arc::new(paint::decode_pixmap(&blob.bytes)?);
|
||||
self.images.insert(cache_key.to_string(), pm.clone());
|
||||
pm
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
if !grey {
|
||||
|
||||
Reference in New Issue
Block a user