diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 650361374..6a29b5b9e 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -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] diff --git a/crates/doc/src/live/mesh.rs b/crates/doc/src/live/mesh.rs index 783e32556..dbbfc66bd 100644 --- a/crates/doc/src/live/mesh.rs +++ b/crates/doc/src/live/mesh.rs @@ -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); } } } diff --git a/crates/engine/src/tests/live.rs b/crates/engine/src/tests/live.rs index 408838566..23904e488 100644 --- a/crates/engine/src/tests/live.rs +++ b/crates/engine/src/tests/live.rs @@ -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) { + 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 = 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:?}"); +}