mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 10:45:45 +00:00
.ai save: RGB and named colors keep their CMYK equivalents when a reopened file is saved
The .ai writers write RGB and Lab values to four decimals next to a CMYK equivalent, but converted that equivalent from the color at full precision. When a file was read back and saved again, its CMYK equivalents differed in the fourth decimal (for example 0.6831 and 0.6832). In the editing data this affected plain RGB colors, RGB gradient stops, global process and spot colors and the header's custom colors; in the legacy .ai writer it affected RGB gradient stops. The CMYK equivalent of an RGB color, and of a global process or spot color, is now converted from the values as they are written, so both saves convert the same values. Lab values of global process and spot colors are now written clamped to the range the reader reads (L 0 to 100, a and b -128 to 127). A plain Lab fill or stroke and a Lab gradient stop are still written as RGB with a CMYK equivalent converted from the Lab values. The reopened file holds them as RGB, so their CMYK equivalents change once on its next save. The art style round-trip test now compares the program exactly; its blanked CMYK values and the four-decimal tolerance are no longer needed.
This commit is contained in:
@@ -294,6 +294,51 @@ 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();
|
||||
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<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_data_is_zstandard_compressed_with_the_header_as_metadata() {
|
||||
let doc = sample();
|
||||
@@ -5870,25 +5915,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();
|
||||
@@ -6210,25 +6236,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`.
|
||||
|
||||
@@ -1344,7 +1344,8 @@ impl<'a> Writer<'a> {
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Lab as 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 };
|
||||
@@ -1359,7 +1360,8 @@ 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))));
|
||||
let _ = writeln!(s, "{}", op("Xa", "XA"));
|
||||
@@ -1368,10 +1370,12 @@ impl<'a> Writer<'a> {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -1467,7 +1471,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
|
||||
@@ -1475,7 +1480,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,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,9 @@ 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). 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.
|
||||
fn stop_color(&mut self, s: &mut String, stop: &GradientStop) {
|
||||
let doc = self.doc;
|
||||
let intent = self.cms.settings().intent;
|
||||
@@ -380,7 +382,7 @@ impl Writer<'_> {
|
||||
&& let Paint::Solid { color: full, swatch: None, .. } = sw.paint
|
||||
{
|
||||
if sw.spot {
|
||||
let cmyk = self.cms.to_cmyk(&doc.linked_color(full, true), intent).map(unit);
|
||||
let cmyk = self.cms.to_cmyk(&doc.linked_color(ai_ops::as_written(full), true), intent).map(unit);
|
||||
ps::push_nums(s, &cmyk.map(f64::from));
|
||||
let rgb = !matches!(full, Color::Cmyk { .. });
|
||||
if rgb {
|
||||
@@ -406,7 +408,8 @@ 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))));
|
||||
s.push_str("2 ");
|
||||
|
||||
@@ -103,6 +103,18 @@ 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 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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user