.ai save: RGB values are written in the working RGB space

Plain RGB fills and strokes and RGB gradient stops were written with
their display RGB values. The reader reads those values as working RGB,
so with a working RGB space other than sRGB a reopened file was saved
with different RGB values and CMYK equivalents. They are now written
with the color's own RGB values, as global process and spot colors
already were.

A spot color in a gradient stop now writes its RGB values from its full
color as the palette writes it (Lab to four decimals and clamped), the
same values its CMYK equivalent comes from.
This commit is contained in:
dotCooCoo
2026-11-12 09:40:31 -08:00
parent 66f6be77ee
commit 812cf5f4cd
4 changed files with 105 additions and 10 deletions
+83
View File
@@ -325,6 +325,8 @@ fn cmyk_equivalents_are_written_again_unchanged() {
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 })));
@@ -339,6 +341,87 @@ fn cmyk_equivalents_are_written_again_unchanged() {
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();
+4 -4
View File
@@ -1343,9 +1343,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 (an RGB color's from its values as written, a Lab
/// color's from its Lab values).
/// 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 };
@@ -1363,7 +1363,7 @@ impl<'a> Writer<'a> {
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"));
}
}
+9 -6
View File
@@ -371,9 +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). 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 is the one the palette writes for it.
/// 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;
@@ -382,11 +384,12 @@ impl Writer<'_> {
&& let Paint::Solid { color: full, swatch: None, .. } = sw.paint
{
if sw.spot {
let cmyk = self.cms.to_cmyk(&doc.linked_color(ai_ops::as_written(full), true), intent).map(unit);
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(' ');
@@ -411,7 +414,7 @@ impl Writer<'_> {
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 ");
}
}
+9
View File
@@ -115,6 +115,15 @@ pub(crate) fn as_written(color: Color) -> Color {
}
}
/// 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;