Merge pull request #6921 from dotCooCoo/fix/ai-save-cmyk-equivalents-stable

.ai save: RGB and named colors keep their CMYK equivalents when a reopened file is saved
This commit is contained in:
BFlat
2026-11-13 02:47:37 +07:00
committed by GitHub
7 changed files with 204 additions and 53 deletions
+129 -38
View File
@@ -294,6 +294,134 @@ fn writing_again_gives_the_same_bytes() {
assert!(first.warnings.is_empty(), "{:?}", first.warnings);
}
/// The CMYK equivalent written beside RGB or Lab values is the one of the values as written (to four
/// decimals, Lab clamped to the range a reader reads), so the reopened file is written with the same
/// numbers: a new document's swatches, and colors of many decimals in fills, gradient stops, global
/// process swatches (RGB and Lab), an RGB spot swatch (in a fill and in a stop), Lab spot swatches
/// and a Lab spot swatch out of range.
#[test]
fn cmyk_equivalents_are_written_again_unchanged() {
let mut doc = Document::new(300.0, 200.0);
let v = |i: u16, s: f32| (f32::from(i) * s + 0.123_456_7).fract();
let rgb = |i: u16| Color::rgb(v(i, 0.137_911_3), v(i, 0.271_828_2), v(i, 0.314_159_3));
let lab = |i: u16| Color::lab(v(i, 0.137_911_3) * 100.0, v(i, 0.271_828_2) * 200.0 - 100.0, v(i, 0.314_159_3) * 200.0 - 100.0);
let glaze = rgb(102);
let swatch = |name: String, c: Color, spot: bool| Swatch { name, paint: solid(c), global: true, spot };
let mut named: Vec<(String, Color, bool)> =
vec![("Glaze".into(), glaze, true), ("Out".into(), Color::lab(104.567_89, -140.123_46, 131.987_65), true)];
for i in 0..8u16 {
named.push((format!("Leaf {i}"), rgb(i + 110), false));
named.push((format!("Fern {i}"), lab(i + 120), false));
named.push((format!("Moss {i}"), lab(i + 130), true));
}
let mut swatches: Vec<Swatch> = doc.swatches.iter().cloned().collect();
swatches.extend(named.iter().map(|(name, c, spot)| swatch(name.clone(), *c, *spot)));
doc.swatches = swatches.into();
let layer = doc.layers[0].id;
for i in 0..24u16 {
let x = f64::from(i % 8) * 30.0 + 10.0;
let y = f64::from(i / 8) * 30.0 + 10.0;
let n = square(&mut doc, Rect::new(x, y, x + 20.0, y + 20.0), rgb(i));
doc.insert(Some(layer), usize::MAX, n).unwrap();
}
let mut stops: Vec<GradientStop> = (0..5u16).map(|i| GradientStop::new(f32::from(i) / 5.0, rgb(i + 50))).collect();
let out = named[1].1;
stops.push(GradientStop { swatch: Some("Out".into()), tint: 0.5, ..GradientStop::new(0.9, out.tinted(0.5)) });
stops.push(GradientStop { swatch: Some("Glaze".into()), tint: 0.7, ..GradientStop::new(1.0, glaze.tinted(0.7)) });
let mut fills = vec![gradient_paint(Gradient::new(GradientKind::Linear, stops), None, None)];
fills.extend(named.iter().map(|(name, c, spot)| linked(name, *c, if *spot { 1.0 } else { 0.6 })));
for (i, fill) in fills.into_iter().enumerate() {
let x = (i % 9) as f64 * 30.0 + 10.0;
let y = (i / 9) as f64 * 30.0 + 110.0;
let n = painted(&mut doc, shapes::rectangle(Rect::new(x, y, x + 20.0, y + 20.0)), vec![AppearanceItem::Fill(FillLayer::new(fill))]);
doc.insert(Some(layer), usize::MAX, n).unwrap();
}
let first = program(&doc);
let again = String::from_utf8(export_ai_editing(&read_back(&doc), &opts()).unwrap().program).unwrap().replace("\r\n", "\n");
same_lines(&again.lines().map(String::from).collect::<Vec<_>>(), &first.lines().map(String::from).collect::<Vec<_>>());
}
/// With a working RGB space other than sRGB, an RGB fill, an RGB gradient stop and an RGB spot
/// color in a stop are written with their working RGB values, the values the reader reads back, so
/// the reopened file is written with the same RGB values and CMYK equivalents.
#[test]
fn rgb_values_are_written_again_unchanged_in_a_wide_gamut_working_space() {
const CHILD: &str = "VECTORCRAFT_TEST_AI_EDITING_WIDE_GAMUT";
if std::env::var_os(CHILD).is_none() {
// Color settings are process-wide: exercise this profile in a separate test process.
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "ai_editing::tests::rgb_values_are_written_again_unchanged_in_a_wide_gamut_working_space", "--nocapture"])
.env(CHILD, "1")
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success() && stdout.contains("test result: ok. 1 passed; 0 failed;"),
"isolated color profile test failed:\n{stdout}\n{}",
String::from_utf8_lossy(&output.stderr),
);
return;
}
use vectorcraft_color::cms;
cms::set_active(&cms::ColorSettings { rgb: cms::WIDE_GAMUT_RGB.into(), ..Default::default() }).unwrap();
assert!(!cms::active().rgb_is_srgb());
let mut doc = Document::new(300.0, 200.0);
let glaze = Color::rgb(0.812_345_6, 0.234_567_8, 0.456_789_1);
let mut swatches: Vec<Swatch> = doc.swatches.iter().cloned().collect();
swatches.push(Swatch { name: "Glaze".into(), paint: solid(glaze), global: true, spot: true });
doc.swatches = swatches.into();
let layer = doc.layers[0].id;
let n = square(&mut doc, Rect::new(10.0, 10.0, 30.0, 30.0), Color::rgb(0.8, 0.4, 0.2));
doc.insert(Some(layer), usize::MAX, n).unwrap();
let stops = vec![
GradientStop::new(0.0, Color::rgb(0.1, 0.7, 0.3)),
GradientStop { swatch: Some("Glaze".into()), tint: 0.7, ..GradientStop::new(1.0, glaze.tinted(0.7)) },
];
let fill = gradient_paint(Gradient::new(GradientKind::Linear, stops), None, None);
let n = painted(&mut doc, shapes::rectangle(Rect::new(40.0, 10.0, 60.0, 30.0)), vec![AppearanceItem::Fill(FillLayer::new(fill))]);
doc.insert(Some(layer), usize::MAX, n).unwrap();
let first = program(&doc);
assert!(first.contains("0.8 0.4 0.2 Xa"), "the fill's working RGB values are written");
let again = String::from_utf8(export_ai_editing(&read_back(&doc), &opts()).unwrap().program).unwrap().replace("\r\n", "\n");
same_lines(&again.lines().map(String::from).collect::<Vec<_>>(), &first.lines().map(String::from).collect::<Vec<_>>());
}
/// A gradient stop of a Lab spot color writes the RGB values of the Lab values as written (to four
/// decimals, clamped to the range a reader reads), the same Lab values its CMYK equivalent and the
/// palette's definition come from: for Lab values of many decimals and for Lab values out of range.
#[test]
fn a_lab_spot_stop_writes_rgb_values_of_its_lab_values_as_written() {
let mut doc = Document::new(300.0, 200.0);
let v = |i: u16, s: f32| (f32::from(i) * s + 0.123_456_7).fract();
let mut spots: Vec<(String, Color)> = vec![("Out".into(), Color::lab(104.567_89, -140.123_46, 131.987_65))];
spots.push(("Rim".into(), Color::lab(55.555_55, 131.234_56, -12.345_67)));
for i in 0..8u16 {
spots
.push((format!("Moss {i}"), Color::lab(v(i, 0.137_911_3) * 100.0, v(i, 0.271_828_2) * 200.0 - 100.0, v(i, 0.314_159_3) * 200.0 - 100.0)));
}
let mut swatches: Vec<Swatch> = doc.swatches.iter().cloned().collect();
swatches.extend(spots.iter().map(|(name, c)| Swatch { name: name.clone(), paint: solid(*c), global: true, spot: true }));
doc.swatches = swatches.into();
let layer = doc.layers[0].id;
for (i, (name, c)) in spots.iter().enumerate() {
let stops = vec![
GradientStop::new(0.0, Color::rgb(0.1, 0.2, 0.3)),
GradientStop { swatch: Some(name.clone()), tint: 0.5, ..GradientStop::new(1.0, c.tinted(0.5)) },
];
let fill = gradient_paint(Gradient::new(GradientKind::Linear, stops), None, None);
let x = i as f64 * 25.0 + 10.0;
let n = painted(&mut doc, shapes::rectangle(Rect::new(x, 10.0, x + 20.0, 30.0)), vec![AppearanceItem::Fill(FillLayer::new(fill))]);
doc.insert(Some(layer), usize::MAX, n).unwrap();
}
let program = program(&doc);
for (name, c) in &spots {
let mut want = String::new();
crate::ps::push_nums(&mut want, &crate::ai_ops::as_written(*c).to_rgb().map(|v| f64::from(crate::ai_ops::unit(v))));
want.push_str(&format!("({name}) 0.5 1 4 "));
assert!(program.lines().any(|l| l.contains(&want)), "no stop of {name} writes {want:?}");
}
}
#[test]
fn the_data_is_zstandard_compressed_with_the_header_as_metadata() {
let doc = sample();
@@ -5872,25 +6000,6 @@ fn outlines(doc: &Document) -> Vec<String> {
out
}
/// Fails with the lines where `got` and `want` differ in more than their numbers' last written
/// decimal (a color read back from four decimals gives its other model's values again from them).
fn near_lines(got: &str, want: &str) {
let near = |a: &str, b: &str| {
let (a, b): (Vec<&str>, Vec<&str>) = (a.split(' ').collect(), b.split(' ').collect());
a.len() == b.len()
&& a.iter().zip(&b).all(|(x, y)| match (x.trim_start_matches("%_").parse::<f64>(), y.trim_start_matches("%_").parse::<f64>()) {
(Ok(x), Ok(y)) => (x - y).abs() <= 2e-3,
_ => x == y,
})
};
let (g, w): (Vec<&str>, Vec<&str>) = (got.lines().collect(), want.lines().collect());
let differ: Vec<String> = (0..g.len().max(w.len()))
.filter(|&i| !matches!((g.get(i), w.get(i)), (Some(a), Some(b)) if near(a, b)))
.map(|i| format!("line {i}:\n got {:?}\n want {:?}", g.get(i), w.get(i)))
.collect();
assert!(differ.is_empty(), "{}", differ.join("\n"));
}
#[test]
fn objects_with_live_effects_and_graphic_styles_are_read_back() {
let doc = styles_document();
@@ -6212,25 +6321,7 @@ fn art_styles_write_the_same_bytes_again() {
assert_eq!(private_data(&doc, &opts()).unwrap().0, private_data(&doc, &opts()).unwrap().0);
// What is read back is written as it was.
let again = export_ai_editing(&read_back(&doc), &opts()).unwrap().program;
near_lines(&rgb_without_cmyk(&shown(&again)), &rgb_without_cmyk(&shown(&first.program)));
}
/// `program` with the CMYK equivalents of plain RGB colors blanked out: the first four numbers of
/// `c m y k r g b Xa` (or `XA`) and of an RGB gradient stop's `c m y k r g b 2 ... Bs`. Named and
/// spot RGB colors (`... (name) tint Xk`, `Xx` and spot stops) are not blanked; the document this
/// is used on has none. The Generic CMYK separation of a color at the edge of the press gamut can
/// land on one of two results that differ by up to 0.01 when the RGB changes in its last float
/// bits, as it does between the first save and the save of the reopened file, and the platform's
/// float math decides which. The RGB values stay compared.
fn rgb_without_cmyk(program: &str) -> String {
let blank = |line: &str| {
let (prefix, rest) = line.strip_prefix("%_").map_or(("", line), |r| ("%_", r));
let t: Vec<&str> = rest.split(' ').collect();
let color = t.len() == 8 && matches!(t[7], "Xa" | "XA");
let stop = t.len() > 8 && t[7] == "2" && t.last() == Some(&"Bs");
if color || stop { format!("{prefix}_ _ _ _ {}", t[4..].join(" ")) } else { line.to_string() }
};
program.lines().map(blank).collect::<Vec<_>>().join("\n")
same_lines(&shown(&again).lines().map(String::from).collect::<Vec<_>>(), &shown(&first.program).lines().map(String::from).collect::<Vec<_>>());
}
/// `n` on the first layer of `doc`.
+14 -9
View File
@@ -1357,8 +1357,9 @@ impl<'a> Writer<'a> {
self.plain_color(*color, stroke);
}
/// A color that names no swatch: `g` (gray), `k` (CMYK), else `c m y k r g b Xa` (RGB, and
/// Lab as RGB), with its CMYK equivalent.
/// A color that names no swatch: `g` (gray), `k` (CMYK), else `c m y k r g b Xa` (RGB in the
/// working RGB space, and Lab as display RGB), with its CMYK equivalent (an RGB color's from its
/// values as written, a Lab color's from its Lab values).
fn plain_color(&mut self, color: Color, stroke: bool) {
let s = &mut self.body;
let op = |fill: &'static str, line: &'static str| if stroke { line } else { fill };
@@ -1373,19 +1374,22 @@ impl<'a> Writer<'a> {
let _ = writeln!(s, "{}", op("k", "K"));
}
Color::Rgb { .. } | Color::Lab { .. } => {
let cmyk = self.cms.to_cmyk(&color, self.cms.settings().intent).map(unit);
let source = if matches!(color, Color::Rgb { .. }) { ai_ops::as_written(color) } else { color };
let cmyk = self.cms.to_cmyk(&source, self.cms.settings().intent).map(unit);
ps::push_nums(s, &cmyk.map(f64::from));
ps::push_nums(s, &color.to_rgb().map(|v| f64::from(unit(v))));
ps::push_nums(s, &ai_ops::rgb_values(color).map(f64::from));
let _ = writeln!(s, "{}", op("Xa", "XA"));
}
}
}
/// A spot (`spot`) or global process color `name` of full color `full` at `tint`, written to
/// `s`: its CMYK equivalent, its RGB or Lab values, the name, how much lighter than the full
/// color, and for any but a CMYK spot color (`x`) the model (0 CMYK, 1 RGB, 2 Lab) before
/// `Xx` (spot) or `Xk` (global process).
/// `s`: its CMYK equivalent (from its RGB or Lab values as written), its RGB or Lab values
/// (clamped as [`ai_ops::as_written`] clamps them), the name, how much lighter than the full
/// color, and for any but a CMYK spot color (`x`) the model (0 CMYK, 1 RGB, 2 Lab) before `Xx`
/// (spot) or `Xk` (global process).
fn named(&self, s: &mut String, name: &str, full: Color, tint: f32, spot: bool, stroke: bool) {
let full = ai_ops::as_written(full);
let cmyk = self.cms.to_cmyk(&self.doc.linked_color(full, spot), self.cms.settings().intent).map(unit);
ps::push_nums(s, &cmyk.map(f64::from));
let model = match full {
@@ -1481,7 +1485,8 @@ impl<'a> Writer<'a> {
true
}
/// The spot color swatches, with their full colors' CMYK equivalents, for the header.
/// The spot color swatches, with their full colors' CMYK equivalents (as the palette writes
/// them), for the header.
pub(super) fn spot_swatches(&self) -> Vec<(String, [f32; 4])> {
let intent = self.cms.settings().intent;
self.doc
@@ -1489,7 +1494,7 @@ impl<'a> Writer<'a> {
.filter(|sw| sw.spot)
.filter_map(|sw| match sw.paint {
Paint::Solid { color, swatch: None, .. } => {
Some((sw.name.clone(), self.cms.to_cmyk(&self.doc.linked_color(color, true), intent).map(unit)))
Some((sw.name.clone(), self.cms.to_cmyk(&self.doc.linked_color(ai_ops::as_written(color), true), intent).map(unit)))
}
_ => None,
})
+11 -5
View File
@@ -11,7 +11,7 @@ use vectorcraft_effects::stroke;
use vectorcraft_geom::{Affine, BezPath, Rect, Shape, Vec2};
use super::{MAX_NEST, Writer, Xy};
use crate::ai_ops::{average, unit};
use crate::ai_ops::{self, average, unit};
use crate::ps;
pub(in crate::ai_editing) const GRADIENTS: &str =
@@ -371,7 +371,11 @@ impl Writer<'_> {
/// A stop's color and its style: gray `lightness 0`, CMYK `c m y k 1`, RGB (and Lab, as RGB)
/// `c m y k r g b 2` with its CMYK equivalent; a spot color its full color's CMYK equivalent,
/// then its RGB values when it isn't CMYK, its name and how much lighter than the full color,
/// then `3` (CMYK), or `1 4` (RGB, and Lab as RGB).
/// then `3` (CMYK), or `1 4` (RGB, and Lab as RGB). RGB values are an RGB color's own (in the
/// working RGB space) and a Lab color's display RGB ([`ai_ops::rgb_values`]). An RGB color's CMYK
/// equivalent is converted from its values as written ([`ai_ops::as_written`]), a Lab color's
/// from its Lab values, and a spot color's CMYK equivalent and RGB values both come from its full
/// color as the palette writes it.
fn stop_color(&mut self, s: &mut String, stop: &GradientStop) {
let doc = self.doc;
let intent = self.cms.settings().intent;
@@ -380,11 +384,12 @@ impl Writer<'_> {
&& let Paint::Solid { color: full, swatch: None, .. } = sw.paint
{
if sw.spot {
let full = ai_ops::as_written(full);
let cmyk = self.cms.to_cmyk(&doc.linked_color(full, true), intent).map(unit);
ps::push_nums(s, &cmyk.map(f64::from));
let rgb = !matches!(full, Color::Cmyk { .. });
if rgb {
ps::push_nums(s, &full.to_rgb().map(|v| f64::from(unit(v))));
ps::push_nums(s, &ai_ops::rgb_values(full).map(f64::from));
}
s.push_str(&ps::bytes(name.as_bytes()));
s.push(' ');
@@ -406,9 +411,10 @@ impl Writer<'_> {
s.push_str("1 ");
}
color @ (Color::Rgb { .. } | Color::Lab { .. }) => {
let cmyk = self.cms.to_cmyk(&color, intent).map(unit);
let source = if matches!(color, Color::Rgb { .. }) { ai_ops::as_written(color) } else { color };
let cmyk = self.cms.to_cmyk(&source, intent).map(unit);
ps::push_nums(s, &cmyk.map(f64::from));
ps::push_nums(s, &color.to_rgb().map(|v| f64::from(unit(v))));
ps::push_nums(s, &ai_ops::rgb_values(color).map(f64::from));
s.push_str("2 ");
}
}
+21
View File
@@ -103,6 +103,27 @@ pub(crate) fn unit(v: f32) -> f32 {
if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }
}
/// `color` as a reader reads it back from the values written for it ([`ps::written`]): RGB values
/// clamped to 0..1, L to 0..100 and a and b to -128..127, each to four decimals; other colors
/// unchanged.
pub(crate) fn as_written(color: Color) -> Color {
let w = |v: f32| ps::written(f64::from(v)) as f32;
match color {
Color::Rgb { r, g, b } => Color::rgb(w(unit(r)), w(unit(g)), w(unit(b))),
Color::Lab { l, a, b } => Color::lab(w(l.clamp(0.0, 100.0)), w(a.clamp(-128.0, 127.0)), w(b.clamp(-128.0, 127.0))),
c => c,
}
}
/// The RGB values written for `color`, clamped to 0..1: an RGB color's own values (in the working
/// RGB space, as a reader reads them back), any other color's display RGB.
pub(crate) fn rgb_values(color: Color) -> [f32; 3] {
match color {
Color::Rgb { r, g, b } => [r, g, b].map(unit),
c => c.to_rgb().map(unit),
}
}
/// The average colour of a gradient's stops (in RGB).
pub(crate) fn average(g: &Gradient) -> Color {
let n = g.stops.len().max(1) as f32;
+22
View File
@@ -755,6 +755,28 @@ fn a_gradient_fill_and_its_stroke_are_one_object() {
assert_eq!(n.appearance.stroke_paint().color(), Some(Color::rgb(1.0, 0.0, 0.0)));
}
/// An RGB gradient stop's CMYK equivalent is the one of its RGB values as written (to four
/// decimals), so the reopened file writes the same stops.
#[test]
fn rgb_stops_are_written_again_unchanged() {
let mut doc = Document::new(100.0, 100.0);
let rgb = |i: u16| {
let v = |s: f32| (f32::from(i) * s + 0.123_456_7).fract();
Color::rgb(v(0.137_911_3), v(0.271_828_2), v(0.314_159_3))
};
let stops = (0..12u16).map(|i| GradientStop::new(f32::from(i) / 11.0, rgb(i))).collect();
let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))));
let n = shape(&mut doc, shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 50.0)), paint, Paint::None);
let layer = doc.layers[0].id;
doc.insert(Some(layer), usize::MAX, n).unwrap();
let stops = |bytes: Vec<u8>| String::from_utf8(bytes).unwrap().lines().filter(|l| l.ends_with("%_Bs")).map(String::from).collect::<Vec<_>>();
let first = write(&doc).bytes;
let again = write(&crate::import(&first).unwrap().document).bytes;
let (first, again) = (stops(first), stops(again));
assert_eq!(first.len(), 12, "{first:?}");
assert_eq!(again, first);
}
/// A gradient from white to #1b2a6b, the colors of #2687's circle, placed at `geom` (None: fitted
/// to the object).
fn white_to_navy(kind: GradientKind, geom: Option<vectorcraft_color::GradientGeom>) -> Paint {
+2 -1
View File
@@ -633,7 +633,8 @@ impl<'a> Writer<'a> {
// Grey is ink coverage; the format's grey is lightness.
Color::Gray { k } => Ink::Gray(unit(1.0 - k)),
Color::Cmyk { c, m, y, k } => Ink::Cmyk([c, m, y, k].map(unit)),
Color::Rgb { r, g, b } => Ink::Rgb(self.cms.to_cmyk(color, intent).map(unit), [r, g, b].map(unit)),
// The CMYK equivalent of the RGB values as written.
Color::Rgb { r, g, b } => Ink::Rgb(self.cms.to_cmyk(&ai_ops::as_written(*color), intent).map(unit), [r, g, b].map(unit)),
Color::Lab { .. } => {
let rgb = color.to_rgb().map(unit);
Ink::Rgb(self.cms.to_cmyk(color, intent).map(unit), rgb)
+5
View File
@@ -36,6 +36,11 @@ pub(crate) fn push_num(s: &mut String, v: f64) {
push_num_places(s, v, 4);
}
/// The value [`num`]`(v)` stands for, as a reader parses it back.
pub(crate) fn written(v: f64) -> f64 {
num(v).parse().unwrap_or(0.0)
}
/// Append `v` to `s` with at most `places` decimals (up to 9), no trailing zeros (0 when not
/// finite).
pub(crate) fn push_num_places(s: &mut String, v: f64, places: u32) {