Fix #3232: a CMYK gradient mesh keeps its K when lightened

Create Gradient Mesh's To Center / To Edge highlight lightened every point
towards RGB white, so a CMYK (or grey) point became display RGB. A patch with
any non-CMYK corner then mixes in RGB, and the PDF writer separated those
colours back to CMYK with K = 0, moving ink off the K plate. Points now
lighten towards white in their own colour model (no ink for CMYK), so the
mesh stays CMYK and K shrinks with the other inks. Flat meshes (highlight 0)
already kept their inks.
This commit is contained in:
Don Omondi
2026-10-30 00:15:58 +07:00
committed by bflatastic
parent 5165b6eb1d
commit 239286ca83
3 changed files with 108 additions and 2 deletions
+19
View File
@@ -221,6 +221,25 @@ mod tests {
assert!(matches!(m.color_at(0, 0, 0.5, 0.5).0, Color::Rgb { .. }));
}
/// #3232: the Appearance highlight lightens each point towards white in its own model, so a
/// CMYK mesh stays CMYK (K shrinks with the other inks) and a grey one stays grey.
#[test]
fn mesh_highlight_keeps_each_points_model() {
let square = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0));
let ink = Color::Cmyk { c: 0.0, m: 0.4, y: 0.6, k: 0.1 };
let m = GradientMesh::for_path(&square, 2, 2, ink, MeshAppearance::ToCenter, 50.0).unwrap();
assert_eq!(m.points[0].color, ink, "corners stay");
let centre = m.points[m.idx(1, 1)].color;
let Color::Cmyk { c, m: mg, y, k } = centre else { panic!("{centre:?}") };
assert!(c.abs() < 1e-6 && (mg - 0.2).abs() < 1e-6 && (y - 0.3).abs() < 1e-6 && (k - 0.05).abs() < 1e-6, "{c} {mg} {y} {k}");
assert!(matches!(m.color_at(0, 0, 0.5, 0.5).0, Color::Cmyk { .. }), "patches mix in CMYK");
let m = GradientMesh::for_path(&square, 2, 2, Color::gray(0.8), MeshAppearance::ToEdge, 100.0).unwrap();
assert_eq!((m.points[0].color, m.points[m.idx(1, 1)].color), (Color::gray(0.0), Color::gray(0.8)));
// RGB lightens in RGB as before.
let m = GradientMesh::for_path(&square, 2, 2, Color::rgb(1.0, 0.0, 0.0), MeshAppearance::ToCenter, 100.0).unwrap();
assert_eq!(m.points[m.idx(1, 1)].color.to_hex(), "#ffffff");
}
/// Alt-clicking a point of a mesh from a file with fewer points than its rows and columns need
/// deletes nothing (it indexed past the points, a panic).
#[test]
+4 -2
View File
@@ -267,7 +267,8 @@ impl GradientMesh {
}
/// Create Gradient Mesh → Appearance: lighten the points towards white, to the centre or to
/// the edges, by at most `highlight` %.
/// the edges, by at most `highlight` %. Each point lightens towards white in its own colour
/// model (paper white, no ink, for CMYK), so a CMYK or grey mesh keeps its inks (#3232).
pub fn highlight(&mut self, appearance: MeshAppearance, highlight: f64) {
let h = (highlight / 100.0).clamp(0.0, 1.0) as f32;
let (rows, cols) = (self.rows as usize, self.cols as usize);
@@ -282,7 +283,8 @@ impl GradientMesh {
};
if w > 0.0 {
let i = self.idx(r, c);
self.points[i].color = lerp_color(&self.points[i].color, &Color::WHITE, w);
let color = self.points[i].color;
self.points[i].color = lerp_color(&color, &Color::WHITE.in_model(color.model()), w);
}
}
}
+85
View File
@@ -1153,3 +1153,88 @@ fn mesh_create_on_an_image_keeps_alpha_and_refuses_linked_and_big_grids() {
assert!(e.contains("linked"), "{e}");
assert_eq!(node(&s, id).kind_label(), "Image", "left as it was");
}
/// A CMYK document with the #3232 rectangle filled C0 M40 Y60 K10, made a 4×4 mesh with `create`.
fn cmyk_mesh(create: Value) -> (Session, NodeId) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 226.77, "height": 170.08, "colorMode": "cmyk"})).unwrap();
let a = rect(&mut s, 28.35, 28.35, 170.08, 113.39);
s.execute("paint.setStroke", &json!({"ids": [a.0], "none": true})).unwrap();
s.execute("paint.setFill", &json!({"ids": [a.0], "color": {"c": 0, "m": 0.4, "y": 0.6, "k": 0.1}})).unwrap();
let mut p = json!({"ids": [a.0], "rows": 4, "cols": 4});
p.as_object_mut().unwrap().extend(create.as_object().unwrap().clone());
s.execute("object.mesh.create", &p).unwrap();
(s, a)
}
/// The fill colours `document.exportPdf {standard: pdfX4}` writes (`… scn` operands), and its warnings.
fn pdf_fill_colours(s: &mut Session) -> (Vec<Vec<f32>>, Vec<String>) {
let v = s.execute("document.exportPdf", &json!({"standard": "pdfX4", "compression": {"compressText": false}})).unwrap();
let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap();
let text = String::from_utf8_lossy(&bytes).into_owned();
let mut out = vec![];
for line in text.lines() {
let words: Vec<&str> = line.split_whitespace().collect();
for (i, w) in words.iter().enumerate() {
if *w == "scn" {
let nums: Vec<f32> = words[..i].iter().rev().map_while(|x| x.parse().ok()).collect();
out.push(nums.into_iter().rev().collect());
}
}
}
(out, serde_json::from_value(v["warnings"].clone()).unwrap())
}
/// #3232: a mesh made from a CMYK fill in a CMYK document keeps its points in CMYK, and the PDF
/// writes them with their K, not separated again from RGB (C/M/Y with K = 0).
#[test]
fn cmyk_mesh_keeps_its_inks_in_pdf() {
for app in ["flat", "center", "edge"] {
let (mut s, a) = cmyk_mesh(json!({"appearance": app, "highlight": 0}));
assert!(mesh_of(&s, a).points.iter().all(|p| p.color == Color::cmyk(0.0, 0.4, 0.6, 0.1)), "{app}");
let (fills, warnings) = pdf_fill_colours(&mut s);
assert!(!fills.is_empty() && warnings.is_empty(), "{app}: {warnings:?}");
for f in &fills {
let [c, m, y, k] = f[..] else { panic!("{app}: not CMYK {f:?}") };
// The writer keeps 8 bits per ink: within one step of C0 M40 Y60 K10.
let near = |a: f32, b: f32| (a - b).abs() <= 1.0 / 255.0 + 1e-6;
assert!(near(c, 0.0) && near(m, 0.4) && near(y, 0.6) && near(k, 0.1), "{app}: {f:?}");
}
}
}
/// #3232: lightening to the centre or the edges lightens CMYK points towards paper white in CMYK,
/// so K shrinks with the other inks instead of being dropped.
#[test]
fn cmyk_mesh_highlight_lightens_in_cmyk() {
for app in ["center", "edge"] {
let (mut s, a) = cmyk_mesh(json!({"appearance": app, "highlight": 50}));
let m = mesh_of(&s, a);
let (corner, centre) = (m.points[0].color, m.points[m.idx(2, 2)].color);
let (lit, plain) = if app == "center" { (centre, corner) } else { (corner, centre) };
assert_eq!(plain, Color::cmyk(0.0, 0.4, 0.6, 0.1), "{app}");
let Color::Cmyk { c, m: mg, y, k } = lit else { panic!("{app}: {lit:?}") };
assert!(c.abs() < 1e-6 && (mg - 0.2).abs() < 1e-5 && (y - 0.3).abs() < 1e-5 && (k - 0.05).abs() < 1e-5, "{app}: {lit:?}");
assert!(m.points.iter().all(|p| matches!(p.color, Color::Cmyk { .. })), "{app}");
let (fills, warnings) = pdf_fill_colours(&mut s);
assert!(warnings.is_empty(), "{app}: no RGB to separate {warnings:?}");
for f in &fills {
let [c, m, y, k] = f[..] else { panic!("{app}: not CMYK {f:?}") };
// Every ink in proportion to C0 M40 Y60 K10, lightened by at most half.
assert!(c < 1.5 / 255.0 && (0.05 - 1.5 / 255.0..=0.1 + 1.5 / 255.0).contains(&k), "{app}: {f:?}");
assert!((m / 0.4 - k / 0.1).abs() < 0.06 && (y / 0.6 - k / 0.1).abs() < 0.06, "{app}: {f:?}");
}
}
}
/// #3232: a CMYK colour given to a mesh point stays CMYK, and the PDF keeps its K.
#[test]
fn cmyk_mesh_point_colour_keeps_k() {
let (mut s, a) = cmyk_mesh(json!({"appearance": "flat"}));
s.execute("object.mesh.setPointColor", &json!({"id": a.0, "index": 0, "color": {"c": 0, "m": 0, "y": 0, "k": 1}})).unwrap();
assert_eq!(mesh_of(&s, a).points[0].color, Color::cmyk(0.0, 0.0, 0.0, 1.0));
let (fills, warnings) = pdf_fill_colours(&mut s);
assert!(warnings.is_empty(), "{warnings:?}");
assert!(fills.iter().all(|f| f.len() == 4 && f[0] < 1.5 / 255.0), "no cyan from a CMY black: {fills:?}");
assert!(fills.iter().any(|f| f[3] > 0.8), "the black corner prints on K: {fills:?}");
}