Merge pull request #6317 from dotCooCoo/fix/pdf-cmyk-gradient-image

PDF export: dithered and Perceptual CMYK gradients stay CMYK
This commit is contained in:
BFlat
2026-11-09 21:46:10 +07:00
committed by GitHub
4 changed files with 296 additions and 23 deletions
+75 -11
View File
@@ -456,6 +456,41 @@ impl Gradient {
}
Some(out)
}
/// A gradient whose stops are all CMYK as a ramp of C, M, Y, K and opacity: the stops' own
/// inks, an off-center midpoint halfway between its two stops' inks, and a Perceptual
/// gradient's curve as [`Self::ink_mixes`] mixes the inks. `None` when a stop is in another
/// model, there are no stops, or [`Self::ink_mixes`] gives none for the curve.
fn ink_ramp(&self) -> Option<Ramp<5>> {
let inks: Vec<[f32; 4]> = self
.stops
.iter()
.map(|s| match s.color {
Color::Cmyk { c, m, y, k } => Some([c, m, y, k]),
_ => None,
})
.collect::<Option<_>>()?;
if inks.is_empty() {
return None;
}
let entry = |i: usize, w: f32, o: f32| {
let (a, b) = (inks[i], inks.get(i + 1).copied().unwrap_or(inks[i]));
let [c, m, y, k]: [f32; 4] = std::array::from_fn(|n| a[n] + (b[n] - a[n]) * w);
[c, m, y, k, o]
};
if !self.method.is_classic() {
return Some(Ramp { stops: self.ink_mixes()?.into_iter().map(|(t, i, w, o)| (t, entry(i, w, o))).collect() });
}
let mut stops = Vec::with_capacity(inks.len());
for (i, s) in self.stops.iter().enumerate() {
stops.push((s.offset, entry(i, 0.0, s.opacity)));
if let Some(n) = self.stops.get(i + 1)
&& (s.midpoint - 0.5).abs() > 1e-3
{
stops.push((s.offset + (n.offset - s.offset) * s.midpoint, entry(i, 0.5, (s.opacity + n.opacity) / 2.0)));
}
}
Some(Ramp { stops })
}
/// The same ramp the other way round: the stops in reverse order at mirrored offsets, each
/// segment's midpoint mirrored with it.
pub fn reverse(&mut self) {
@@ -895,19 +930,45 @@ impl GradientPaint {
fill: bool,
rgb: &dyn Fn(&Color) -> [f32; 3],
) -> impl Iterator<Item = [u8; 4]> + use<> {
self.grid(Ramp::new(&self.gradient, rgb), bounds, area, cols, rows, cells, fill, false)
}
/// [`Self::pixels`] of a linear or radial gradient whose stops are all CMYK, as C, M, Y, K
/// and opacity levels mixed from the stops' inks; `None` when a stop is in another model,
/// there are no stops, or a Perceptual curve has too many stops for [`Gradient::ink_mixes`].
/// Dither's noise adds no ink where the ramp has none.
pub fn ink_pixels(&self, bounds: Rect, area: Rect, cols: u16, rows: u16, fill: bool) -> Option<impl Iterator<Item = [u8; 5]> + use<>> {
let ramp = self.gradient.ink_ramp()?;
Some(self.grid(ramp, bounds, area, cols, rows, [0..cols, 0..rows], fill, true))
}
/// `ramp` sampled at each cell's center in columns `cells[0]` and rows `cells[1]` of the grid,
/// its last channel (opacity) rounded and each other channel with dither's noise, except
/// (`inks`) where that channel is empty.
#[allow(clippy::too_many_arguments)]
fn grid<const N: usize>(
&self,
ramp: Ramp<N>,
bounds: Rect,
area: Rect,
cols: u16,
rows: u16,
cells: [std::ops::Range<u16>; 2],
fill: bool,
inks: bool,
) -> impl Iterator<Item = [u8; N]> + use<N> {
let (kind, geom, dither) = (self.gradient.kind, self.resolve(bounds), fill && self.gradient.dither);
let ramp = Ramp::new(&self.gradient, rgb);
let (cw, ch) = (area.width() / f64::from(cols.max(1)), area.height() / f64::from(rows.max(1)));
let (x0, y0) = (area.x0, area.y0);
let [across, down] = cells.map(|r| u32::from(r.start)..u32::from(r.end));
down.flat_map(move |j| across.clone().map(move |i| (i, j))).map(move |(i, j)| {
let p = Point::new(x0 + (f64::from(i) + 0.5) * cw, y0 + (f64::from(j) + 0.5) * ch);
let c = ramp.at(geom.param_at(kind, p) as f32);
let level = |k: usize| {
let noise = if dither { dither_noise(i, j, k as u32) } else { 0.0 };
std::array::from_fn(|k| {
if k + 1 == N {
return (c[k].clamp(0.0, 1.0) * 255.0).round() as u8;
}
let noise = if dither && !(inks && c[k] <= 0.0) { dither_noise(i, j, k as u32) } else { 0.0 };
(c[k].clamp(0.0, 1.0) * 255.0 + noise).round().clamp(0.0, 255.0) as u8
};
[level(0), level(1), level(2), (c[3].clamp(0.0, 1.0) * 255.0).round() as u8]
})
})
}
}
@@ -916,9 +977,9 @@ impl GradientPaint {
/// (display RGB, or an ink's coverage): its stops, midpoints and a Perceptual gradient's
/// in-between colours as stops (as [`Gradient::expanded_stops`]), each turned into that space
/// once, with colour and opacity mixed linearly between them, each on its own (as the renderer's
/// vector gradients mix them).
pub struct Ramp {
stops: Vec<(f32, [f32; 4])>,
/// vector gradients mix them). `N` counts the channels, with opacity last.
pub struct Ramp<const N: usize = 4> {
stops: Vec<(f32, [f32; N])>,
}
impl Ramp {
@@ -932,10 +993,13 @@ impl Ramp {
.collect();
Self { stops }
}
}
impl<const N: usize> Ramp<N> {
/// Unpremultiplied colour and opacity at `t`, padded past the ends (transparent black
/// without stops).
pub fn at(&self, t: f32) -> [f32; 4] {
let Some(&(t0, first)) = self.stops.first() else { return [0.0; 4] };
pub fn at(&self, t: f32) -> [f32; N] {
let Some(&(t0, first)) = self.stops.first() else { return [0.0; N] };
// Not after the first stop (or not a number): its colour.
if t.partial_cmp(&t0) != Some(std::cmp::Ordering::Greater) {
return first;
@@ -947,7 +1011,7 @@ impl Ramp {
return std::array::from_fn(|k| ca[k] + (cb[k] - ca[k]) * u);
}
}
self.stops.last().map_or([0.0; 4], |s| s.1)
self.stops.last().map_or([0.0; N], |s| s.1)
}
}
+93
View File
@@ -604,3 +604,96 @@ fn ink_mixes_use_the_mix_position_when_the_lightness_turns_back() {
}
assert!(mixes.windows(2).filter(|p| p[1].1 == 0).all(|p| p[0].2 <= p[1].2), "the weights rise: {mixes:?}");
}
fn cmyk_stop(offset: f32, c: f32, m: f32, y: f32, k: f32) -> GradientStop {
GradientStop::new(offset, Color::cmyk(c, m, y, k))
}
/// The `n` × 1 grid of `g` as a left-to-right linear gradient's ink pixels.
fn ink_row(g: Gradient, n: u16, fill: bool) -> Vec<[u8; 5]> {
let b = Rect::new(0.0, 0.0, f64::from(n), 1.0);
GradientPaint::new(g).ink_pixels(b, b, n, 1, fill).expect("CMYK stops").collect()
}
#[test]
fn ink_pixels_mix_the_stops_inks_in_cmyk() {
let mut g = Gradient::new(GradientKind::Linear, vec![cmyk_stop(0.0, 1.0, 0.0, 0.0, 0.0), cmyk_stop(1.0, 0.0, 0.0, 0.0, 1.0)]);
g.stops[1].opacity = 0.5;
let px = ink_row(g.clone(), 10, true);
let at = |x: f32| (x * 255.0).round() as u8;
for (i, p) in px.iter().enumerate() {
let t = (i as f32 + 0.5) / 10.0;
assert_eq!(*p, [at(1.0 - t), 0, 0, at(t), at(1.0 - t * 0.5)], "pixel {i}");
}
// An off-center midpoint is halfway between the two stops' inks, not a display RGB mix.
g.stops[0].midpoint = 0.25;
let px = ink_row(g.clone(), 4, false);
assert_eq!(&px[0][..4], &[at(0.75), 0, 0, at(0.25)], "halfway to the midpoint");
assert_eq!(&px[1][..4], &[at(0.5 * 5.0 / 6.0), 0, 0, at(0.5 + 0.5 / 6.0)], "a sixth of the way past it");
// A hard stop keeps both sides.
let hard = Gradient::new(
GradientKind::Linear,
vec![
cmyk_stop(0.0, 1.0, 0.0, 0.0, 0.0),
cmyk_stop(0.5, 1.0, 0.0, 0.0, 0.0),
cmyk_stop(0.5, 0.0, 1.0, 0.0, 0.0),
cmyk_stop(1.0, 0.0, 1.0, 0.0, 0.0),
],
);
let px = ink_row(hard, 4, false);
assert_eq!((&px[1][..4], &px[2][..4]), (&[255, 0, 0, 0][..], &[0, 255, 0, 0][..]));
}
#[test]
fn ink_pixels_dither_only_the_inks_the_ramp_has() {
let mut g = Gradient::new(GradientKind::Linear, vec![cmyk_stop(0.0, 0.2, 0.0, 0.0, 0.4), cmyk_stop(1.0, 0.8, 0.0, 0.0, 0.4)]);
g.dither = true;
let noisy = ink_row(g.clone(), 64, true);
let plain = ink_row(g.clone(), 64, false);
assert_ne!(noisy, plain, "a fill has noise, a stroke none");
for (n, p) in noisy.iter().zip(&plain) {
assert_eq!((n[1], n[2]), (0, 0), "no ink on the empty plates: {n:?}");
assert!(n[0].abs_diff(p[0]) <= 1 && n[3].abs_diff(p[3]) <= 1, "{n:?} {p:?}");
assert_eq!(n[4], 255, "opacity has no noise");
}
assert!(noisy.iter().zip(&plain).any(|(n, p)| n[3] != p[3]), "K gets its own noise");
assert!(noisy.iter().zip(&plain).any(|(n, p)| (n[0] != p[0]) != (n[3] != p[3])), "the noise differs from channel to channel");
}
#[test]
fn ink_pixels_follow_the_perceptual_curve_as_ink_mixes() {
let mut g = Gradient::new(GradientKind::Linear, vec![cmyk_stop(0.0, 0.0, 0.0, 0.0, 0.0), cmyk_stop(1.0, 1.0, 0.0, 0.0, 0.0)]);
g.method = GradientMethod::Perceptual;
let mixes = g.ink_mixes().unwrap();
let ramp = g.ink_ramp().unwrap();
assert_eq!(ramp.stops.len(), mixes.len());
for ((t, ink), (o, i, w, _)) in ramp.stops.iter().zip(&mixes) {
assert_eq!((*t, ink[0]), (*o, if *i == 0 { *w } else { 1.0 }), "cyan at {t}");
}
let px = ink_row(g, 2, false);
assert!(px[0][0] > 0 && px[0][0] < px[1][0], "{px:?}");
assert!(px.iter().all(|p| p[1] == 0 && p[2] == 0 && p[3] == 0), "{px:?}");
}
#[test]
fn ink_pixels_need_every_stop_in_cmyk() {
let b = Rect::new(0.0, 0.0, 2.0, 1.0);
let with = |stop: GradientStop| GradientPaint::new(Gradient::new(GradientKind::Linear, vec![cmyk_stop(0.0, 1.0, 0.0, 0.0, 0.0), stop]));
assert!(with(cmyk_stop(1.0, 0.0, 0.0, 0.0, 1.0)).ink_pixels(b, b, 2, 1, true).is_some());
for other in [Color::rgb(1.0, 0.0, 0.0), Color::gray(0.5), Color::lab(50.0, 10.0, 10.0)] {
assert!(with(GradientStop::new(1.0, other)).ink_pixels(b, b, 2, 1, true).is_none(), "{other:?}");
}
assert!(GradientPaint::new(Gradient::new(GradientKind::Linear, vec![])).ink_pixels(b, b, 2, 1, true).is_none(), "no stops");
}
/// A Perceptual curve over the limit of [`Gradient::ink_mixes`] has no inks; a Classic gradient of as many stops does.
#[test]
fn ink_pixels_stop_at_the_ink_mixes_limit() {
let stops: Vec<_> = (0..=MAX_INK_TINTS).map(|i| cmyk_stop(i as f32 / MAX_INK_TINTS as f32, (i % 2) as f32, 0.0, 0.0, 0.0)).collect();
let classic = Gradient::new(GradientKind::Linear, stops);
let perceptual = Gradient { method: GradientMethod::Perceptual, ..classic.clone() };
assert!(!perceptual.fits_ink_tints());
let b = Rect::new(0.0, 0.0, 2.0, 1.0);
assert!(GradientPaint::new(perceptual).ink_pixels(b, b, 2, 1, false).is_none());
assert!(GradientPaint::new(classic).ink_pixels(b, b, 2, 1, false).is_some());
}
+30 -11
View File
@@ -435,20 +435,27 @@ impl Exporter<'_> {
/// Warn when gradient `g`, drawn as an image, has stops linked to a spot swatch: the image is
/// in process colors, so the spot inks have no plate of their own.
fn warn_spot_image(&mut self, g: &vectorcraft_color::GradientPaint) {
let doc = self.doc;
let from_stops = g.gradient.kind != GradientKind::Freeform || !g.has_freeform_points();
if from_stops && g.gradient.stops.iter().any(|s| s.swatch.as_deref().is_some_and(|n| doc.swatch(n).is_some_and(|w| w.spot))) {
if from_stops && self.has_spot_stop(&g.gradient) {
self.warn(
"spot colors in gradients exported as images (freeform or dithered gradients, and Perceptual ones with RGB or Lab stops or too many stops) are written in process colors",
);
}
}
/// Is a stop of `g` linked to a spot swatch?
fn has_spot_stop(&self, g: &vectorcraft_color::Gradient) -> bool {
let doc = self.doc;
g.stops.iter().any(|s| s.swatch.as_deref().is_some_and(|n| doc.swatch(n).is_some_and(|w| w.spot)))
}
/// Draw gradient `g`, placed on `bounds`, as an image over `area`, sampled at the document's
/// raster effects resolution where it is drawn (`area` as the current transform sizes it, so
/// scaled type gets its pixels too): a freeform gradient's colour field (at most
/// [`MAX_FIELD_PX`] a side), or a linear or radial gradient (at most [`MAX_GRADIENT_PX`] a side),
/// with dither's noise when it paints a fill (`fill`).
/// with dither's noise when it paints a fill (`fill`). A linear or radial gradient whose stops
/// are all CMYK and not linked to a spot swatch is a CMYK image, unless its Perceptual curve
/// has too many stops; any other is an RGB image.
pub(super) fn field_image(&mut self, s: &mut Surface, g: &vectorcraft_color::GradientPaint, bounds: Rect, area: Rect, fill: bool) {
use vectorcraft_color::cms::Model;
use vectorcraft_color::freeform::{painted_box, spread_scale};
@@ -469,24 +476,36 @@ impl Exporter<'_> {
let (cols, rows) = (along(w), along(h));
let field = freeform.then(|| g.freeform_on(b).model_field(spread_scale(b)));
let cmyk_doc = self.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk;
let img = match &field {
Some(field) if field.model() == Some(Model::Cmyk) || (cmyk_doc && field.model() == Some(Model::Gray)) => {
if !freeform {
self.warn("linear and radial gradients drawn as pixels are exported as images");
}
let ramp_inks = if freeform || self.has_spot_stop(&g.gradient) { None } else { g.ink_pixels(b, area, cols, rows, fill) };
let ink_image = |inks, alpha| {
let convert = if self.convert { self.out.cmyk_pixels() } else { None };
crate::images::from_inks(inks, alpha, cols.into(), rows.into(), self.interpolate, convert)
};
let img = match (&field, ramp_inks) {
(Some(field), _) if field.model() == Some(Model::Cmyk) || (cmyk_doc && field.model() == Some(Model::Gray)) => {
let (mut inks, mut alpha) = (Vec::new(), Vec::new());
for (color, a) in field.grid(area, cols, rows) {
let [c, m, y, k] = color.to_cmyk();
inks.extend([q(c), q(m), q(y), q(k)]);
alpha.push(q(a));
}
let convert = if self.convert { self.out.cmyk_pixels() } else { None };
crate::images::from_inks(inks, alpha, cols.into(), rows.into(), self.interpolate, convert)
ink_image(inks, alpha)
}
(_, Some(pixels)) => {
let (mut inks, mut alpha) = (Vec::new(), Vec::new());
for [c, m, y, k, a] in pixels {
inks.extend([c, m, y, k]);
alpha.push(a);
}
ink_image(inks, alpha)
}
_ => {
let rgba: Vec<u8> = match &field {
Some(field) => field.display_grid(area, cols, rows).flat_map(|(c, a)| [q(c[0]), q(c[1]), q(c[2]), q(a)]).collect(),
None => {
self.warn("linear and radial gradients drawn as pixels are exported as images");
g.pixels(b, area, cols, rows, fill, &|c| c.to_rgb()).flatten().collect()
}
None => g.pixels(b, area, cols, rows, fill, &|c| c.to_rgb()).flatten().collect(),
};
let pixels = if self.convert { self.out.pixels() } else { None };
crate::images::from_rgba_in(rgba, cols.into(), rows.into(), self.interpolate, pixels)
+98 -1
View File
@@ -8,7 +8,7 @@ use craft_pdf::syntax::Pdf;
use craft_pdf::syntax::object::{Array, Name, Object, Stream};
use serde_json::json;
use vectorcraft_color::cms::{self, DEVICE_CMYK, GENERIC_CMYK, SRGB};
use vectorcraft_color::{Color, Freeform, FreeformPoint, Gradient, GradientKind, GradientPaint, Paint};
use vectorcraft_color::{Color, Freeform, FreeformPoint, Gradient, GradientKind, GradientMethod, GradientPaint, GradientStop, Paint, Swatch};
use vectorcraft_doc::cmyk::Inks;
use vectorcraft_doc::{Appearance, ColorMode, Document, ImageBlob, ImageObject, Node, NodeKind};
use vectorcraft_geom::{Affine, Point, Rect, shapes};
@@ -514,6 +514,103 @@ fn freeform_gradients_of_cmyk_points_are_written_as_cmyk_images() {
assert_ne!(w[0].space, "DeviceCMYK", "converted to RGB");
}
/// A 100 × 100 pt document of `mode` whose square is filled with `g`, at one pixel a point.
fn gradient_doc(mode: ColorMode, g: Gradient) -> Document {
let mut d = Document::new_with_mode(100.0, 100.0, mode);
d.raster_effects_ppi = 72.0;
let square = Node::path(
d.alloc_id(),
shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)),
Appearance::basic(Paint::Gradient(Box::new(GradientPaint::new(g))), Paint::None, 0.0),
);
let l = d.layers[0].id;
d.insert(Some(l), 0, square).unwrap();
d
}
/// A gradient of `stops`, dithered or Perceptual.
fn drawn_as_pixels(kind: GradientKind, stops: Vec<GradientStop>, dither: bool) -> Gradient {
let method = if dither { GradientMethod::Classic } else { GradientMethod::Perceptual };
Gradient { dither, method, ..Gradient::new(kind, stops) }
}
fn cyan_to_black() -> Vec<GradientStop> {
vec![GradientStop::new(0.0, Color::cmyk(1.0, 0.0, 0.0, 0.0)), GradientStop::new(1.0, Color::cmyk(0.0, 0.0, 0.0, 1.0))]
}
#[test]
fn dithered_and_perceptual_gradients_of_cmyk_stops_are_written_as_cmyk_images() {
for mode in [ColorMode::Cmyk, ColorMode::Rgb] {
for kind in [GradientKind::Linear, GradientKind::Radial] {
for dither in [true, false] {
let d = gradient_doc(mode, drawn_as_pixels(kind, cyan_to_black(), dither));
let out = export_with_report(&d, &opts(json!({}))).unwrap();
let w = written(&out.bytes);
assert_eq!(w.len(), 1, "{mode:?} {kind:?} dither {dither}: opaque, no soft mask: {w:?}");
assert_eq!(w[0].space, "DeviceCMYK", "{mode:?} {kind:?} dither {dither}");
let back = import(&out.bytes).unwrap();
let inks = all_images(&back)[0].cmyk().unwrap();
assert_eq!((inks.width, inks.height), (100, 100));
assert!(inks.data.chunks(4).all(|p| p[1] == 0 && p[2] == 0), "{mode:?} {kind:?} dither {dither}: only cyan and black");
if kind == GradientKind::Linear {
let row = &inks.data[50 * 400..51 * 400];
assert!(close(&row[..4], &[254, 0, 0, 1], 3), "{mode:?} dither {dither}: {:?}", &row[..4]);
assert!(close(&row[396..], &[1, 0, 0, 254], 3), "{mode:?} dither {dither}: {:?}", &row[396..]);
if dither {
assert!(close(&row[50 * 4..50 * 4 + 4], &[127, 0, 0, 128], 3), "{mode:?}: {:?}", &row[50 * 4..50 * 4 + 4]);
}
}
}
}
}
// Opacity goes in a soft mask, and the inks stay.
let mut stops = cyan_to_black();
stops[1].opacity = 0.5;
let w = written(
&export_with_report(&gradient_doc(ColorMode::Cmyk, drawn_as_pixels(GradientKind::Linear, stops, true)), &opts(json!({}))).unwrap().bytes,
);
assert_eq!(w.len(), 2, "the image and its soft mask: {w:?}");
assert!(w.iter().any(|i| i.space == "DeviceCMYK") && w.iter().any(|i| i.space == "DeviceGray"), "{w:?}");
// The Output settings convert the inks like any CMYK image's.
let d = gradient_doc(ColorMode::Cmyk, drawn_as_pixels(GradientKind::Linear, cyan_to_black(), true));
let w = written(&export_with_report(&d, &opts(json!({"output": {"conversion": "destination", "destination": SRGB}}))).unwrap().bytes);
assert_eq!(w.len(), 1, "{w:?}");
assert_ne!(w[0].space, "DeviceCMYK", "converted to RGB");
}
/// A stop in another model, or linked to a spot swatch, keeps the RGB image.
#[test]
fn gradients_with_a_stop_that_is_not_process_cmyk_stay_rgb_images() {
let cyan = || GradientStop::new(0.0, Color::cmyk(1.0, 0.0, 0.0, 0.0));
let others = [Color::rgb(1.0, 0.0, 0.0), Color::gray(0.5), Color::lab(50.0, 10.0, 10.0)];
for mode in [ColorMode::Cmyk, ColorMode::Rgb] {
for other in others {
for dither in [true, false] {
let d = gradient_doc(mode, drawn_as_pixels(GradientKind::Linear, vec![cyan(), GradientStop::new(1.0, other)], dither));
let w = written(&export_with_report(&d, &opts(json!({}))).unwrap().bytes);
assert_eq!(w.len(), 1, "{mode:?} {other:?}: {w:?}");
assert_eq!(w[0].space, "DeviceRGB", "{mode:?} {other:?} dither {dither}");
}
}
}
// A stop of a spot swatch whose color is CMYK: a dithered fill is an RGB image with a warning.
let gold = Color::cmyk(0.0, 0.2, 0.8, 0.1);
let mut stop = GradientStop::new(1.0, gold);
stop.swatch = Some("Gold".into());
let mut d = gradient_doc(ColorMode::Cmyk, drawn_as_pixels(GradientKind::Linear, vec![cyan(), stop.clone()], true));
d.swatches.push(Swatch { name: "Gold".into(), paint: Paint::solid(gold), global: true, spot: true });
let out = export_with_report(&d, &opts(json!({}))).unwrap();
let w = written(&out.bytes);
assert_eq!(w.len(), 1, "{w:?}");
assert_eq!(w[0].space, "DeviceRGB");
assert!(out.warnings.iter().any(|m| m.contains("written in process colors")), "{:?}", out.warnings);
// A stop of a global process swatch is written as its CMYK color.
let mut d = gradient_doc(ColorMode::Cmyk, drawn_as_pixels(GradientKind::Linear, vec![cyan(), stop], true));
d.swatches.push(Swatch { name: "Gold".into(), paint: Paint::solid(gold), global: true, spot: false });
let w = written(&export_with_report(&d, &opts(json!({}))).unwrap().bytes);
assert_eq!(w[0].space, "DeviceCMYK", "{w:?}");
}
#[test]
fn freeform_gradients_of_lab_points_are_written_as_their_lab_mix() {
let d = freeform_doc(ColorMode::Rgb, Color::lab(90.0, -60.0, 40.0), Color::lab(30.0, 60.0, -40.0), 1.0);